Эх сурвалжийг харах

feat: change some settings at runtime, and be honest about the rest

Thirty-one configuration keys were readable only by editing a JSON file
and restarting. Five of them are consumed per operation and can now be
changed while the service runs: both token lifetimes, the load-balancing
strategy, the runner heartbeat timeout and the offline-removal delay.

Overrides live in the database and the file keeps supplying defaults, so
a change survives a restart, an untouched key stays untouched, and the
file remains the record of what was deliberately configured.

The other twenty-six are listed too, and this is the part worth keeping:
each says plainly whether it can move and why not. A port is bound when
the socket is, the dispatch pool is sized when it is built, the upload
cap is handed to httplib at construction. Showing them read-only makes
the page a description of the running instance rather than a half-truth.

Two keys are neither readable nor writable. auth.jwt_secret signs every
token and credentials.master_key decrypts every stored credential, so
their entries carry a name, a type and a reason, and no value field at
all - not a redacted one, not a length. A value that is never in a
response cannot leak from one.

A write validates everything before it applies anything, so a request
that names one bad key does not leave its neighbours half-applied, and a
refusal says what the bound was: "must be between 30 and 86400 seconds
(got -5)" rather than "invalid".

A body carrying neither sets nor clears is refused. It parses as JSON,
matches no field, and used to answer 200 having done nothing - a settings
page saving into a mistyped payload would have looked like it worked.

Verified against the running service: setting the access-token lifetime
to 1234 changed the next login's token from 900 to 1234 seconds with no
restart, and clearing it put it back. Unknown, restart-required, secret,
out-of-range and wrong-type writes are each refused with a reason. The
GET body contains no secret value.

81 passed, 0 failed.
fszontagh 1 сар өмнө
parent
commit
8f6ba197a6

+ 17 - 0
CMakeLists.txt

@@ -69,6 +69,20 @@ target_link_libraries(smartbotic_storage PUBLIC
     smartbotic::db-client
 )
 
+# Settings library (runtime overrides over the file-based config)
+add_library(smartbotic_settings STATIC
+    lib/settings/settings_store.cpp
+)
+target_include_directories(smartbotic_settings PUBLIC
+    ${CMAKE_CURRENT_SOURCE_DIR}/lib
+)
+target_link_libraries(smartbotic_settings PUBLIC
+    smartbotic_common
+    smartbotic_logging
+    smartbotic_storage
+    nlohmann_json::nlohmann_json
+)
+
 # Crypto library
 add_library(smartbotic_crypto STATIC
     lib/crypto/aes_gcm.cpp
@@ -180,6 +194,8 @@ add_executable(smartbotic-webserver
     src/webserver/api/database_controller.cpp
     src/webserver/api/file_controller.cpp
     src/webserver/api/proxy_controller.cpp
+    src/webserver/api/settings_controller.cpp
+    src/webserver/settings/settings_registry.cpp
 )
 target_include_directories(smartbotic-webserver PRIVATE
     ${CMAKE_CURRENT_SOURCE_DIR}/src
@@ -194,6 +210,7 @@ target_link_libraries(smartbotic-webserver PRIVATE
     smartbotic_logging
     smartbotic_config
     smartbotic_storage
+    smartbotic_settings
     smartbotic_credentials
     smartbotic_proto
     httplib::httplib

+ 48 - 0
lib/settings/settings_accessor.hpp

@@ -0,0 +1,48 @@
+#pragma once
+
+#include <string>
+
+#include <nlohmann/json.hpp>
+
+#include "settings_store.hpp"
+
+namespace smartbotic::settings {
+
+// Resolves a key as override-then-file-default, mirroring
+// config::Config::getOr's own signature on purpose: a call site converts from
+// "read a value captured at startup" to "ask the accessor" by replacing
+// `cfg.getOr<T>(key, literalDefault)` (evaluated once, in loadConfig) with
+// `settings.getOr<T>(key, resolvedFileValue)` (evaluated every time the
+// setting is consulted). `resolvedFileValue` is normally the field that used
+// to hold the startup-captured value, so a caller with no override still
+// behaves exactly as before.
+//
+// Backed by SettingsStore's in-memory cache, so this never reaches the
+// database on the read path - the cost of "read per operation" is one
+// mutex-guarded map lookup.
+class SettingsAccessor {
+public:
+    explicit SettingsAccessor(SettingsStore& store) : store_(store) {}
+
+    template <typename T>
+    T getOr(const std::string& key, T file_default) const {
+        auto override_value = store_.get(key);
+        if (!override_value.has_value()) {
+            return file_default;
+        }
+        try {
+            return override_value->get<T>();
+        } catch (...) {
+            // A stored override that no longer parses as T (e.g. the type was
+            // tightened after it was written) is not a reason to fail an
+            // operation - fall back to the file default, same as an absent
+            // override.
+            return file_default;
+        }
+    }
+
+private:
+    SettingsStore& store_;
+};
+
+} // namespace smartbotic::settings

+ 157 - 0
lib/settings/settings_store.cpp

@@ -0,0 +1,157 @@
+#include "settings_store.hpp"
+
+#include "common/time_utils.hpp"
+#include "common/uuid.hpp"
+#include "logging/logger.hpp"
+
+namespace smartbotic::settings {
+
+namespace {
+// A single document holds every override, so reload() is one read rather than
+// a query over N rows - this collection never grows past the size of the
+// settings registry itself.
+constexpr const char* kOverridesCollection = "settings_overrides";
+constexpr const char* kOverridesDocId = "overrides";
+constexpr const char* kAuditCollection = "settings_audit";
+} // namespace
+
+nlohmann::json SettingChange::toJson() const {
+    nlohmann::json j{
+        {"key", key},
+        {"cleared", cleared},
+        {"changedBy", changed_by},
+        {"changedByUsername", changed_by_username},
+        {"changedAt", changed_at},
+    };
+    if (!cleared) {
+        j["value"] = value;
+    }
+    return j;
+}
+
+SettingsStore::SettingsStore(storage::StorageClient& storage) : storage_(storage) {
+    reload();
+}
+
+nlohmann::json SettingsStore::currentDocOrEmpty() const {
+    auto result = storage_.get(kOverridesCollection, kOverridesDocId);
+    if (result.failed()) {
+        return nlohmann::json::object();
+    }
+    return result.value();
+}
+
+void SettingsStore::reload() {
+    auto doc = currentDocOrEmpty();
+    std::unordered_map<std::string, nlohmann::json> fresh;
+    for (auto it = doc.begin(); it != doc.end(); ++it) {
+        // Metadata keys (_id, _created_at, ...) are not settings.
+        if (!it.key().empty() && it.key().front() == '_') {
+            continue;
+        }
+        fresh[it.key()] = it.value();
+    }
+
+    std::lock_guard<std::mutex> lock(mutex_);
+    cache_ = std::move(fresh);
+}
+
+std::optional<nlohmann::json> SettingsStore::get(const std::string& key) const {
+    std::lock_guard<std::mutex> lock(mutex_);
+    auto it = cache_.find(key);
+    if (it == cache_.end()) {
+        return std::nullopt;
+    }
+    return std::optional<nlohmann::json>(it->second);
+}
+
+std::unordered_map<std::string, nlohmann::json> SettingsStore::getAll() const {
+    std::lock_guard<std::mutex> lock(mutex_);
+    return cache_;
+}
+
+common::Result<void> SettingsStore::setOverride(const std::string& key, const nlohmann::json& value,
+                                                const std::string& changed_by,
+                                                const std::string& changed_by_username) {
+    auto doc = currentDocOrEmpty();
+    doc[key] = value;
+
+    auto upserted = storage_.upsert(kOverridesCollection, doc, kOverridesDocId);
+    if (upserted.failed()) {
+        return upserted.error();
+    }
+
+    SettingChange change;
+    change.key = key;
+    change.value = value;
+    change.cleared = false;
+    change.changed_by = changed_by;
+    change.changed_by_username = changed_by_username;
+    change.changed_at = common::TimeUtils::nowMs();
+    auto audit = storage_.insert(kAuditCollection, change.toJson());
+    if (audit.failed()) {
+        // The override is already live; losing the audit trail entry is a
+        // lesser failure than pretending the change didn't happen, so this
+        // is logged rather than rolled back.
+        LOG_WARN("Settings override for '{}' recorded, but the audit entry could not be "
+                 "written: {}", key, audit.error().message());
+    }
+
+    reload();
+    return {};
+}
+
+common::Result<void> SettingsStore::clearOverride(const std::string& key,
+                                                  const std::string& changed_by,
+                                                  const std::string& changed_by_username) {
+    auto doc = currentDocOrEmpty();
+    doc.erase(key);
+
+    auto upserted = storage_.upsert(kOverridesCollection, doc, kOverridesDocId);
+    if (upserted.failed()) {
+        return upserted.error();
+    }
+
+    SettingChange change;
+    change.key = key;
+    change.cleared = true;
+    change.changed_by = changed_by;
+    change.changed_by_username = changed_by_username;
+    change.changed_at = common::TimeUtils::nowMs();
+    auto audit = storage_.insert(kAuditCollection, change.toJson());
+    if (audit.failed()) {
+        LOG_WARN("Settings override for '{}' cleared, but the audit entry could not be "
+                 "written: {}", key, audit.error().message());
+    }
+
+    reload();
+    return {};
+}
+
+common::Result<std::vector<SettingChange>> SettingsStore::recentChanges(int limit) const {
+    storage::QueryOptions options;
+    options.page = 1;
+    options.page_size = limit;
+    options.sorts.push_back({"changedAt", false});
+
+    auto result = storage_.query(kAuditCollection, options);
+    if (result.failed()) {
+        return result.error();
+    }
+
+    std::vector<SettingChange> changes;
+    changes.reserve(result.value().documents.size());
+    for (const auto& doc : result.value().documents) {
+        SettingChange change;
+        change.key = doc.value("key", std::string());
+        change.cleared = doc.value("cleared", false);
+        change.value = doc.value("value", nlohmann::json());
+        change.changed_by = doc.value("changedBy", std::string());
+        change.changed_by_username = doc.value("changedByUsername", std::string());
+        change.changed_at = doc.value("changedAt", int64_t{0});
+        changes.push_back(std::move(change));
+    }
+    return changes;
+}
+
+} // namespace smartbotic::settings

+ 74 - 0
lib/settings/settings_store.hpp

@@ -0,0 +1,74 @@
+#pragma once
+
+#include <mutex>
+#include <optional>
+#include <string>
+#include <unordered_map>
+#include <vector>
+
+#include <nlohmann/json.hpp>
+
+#include "common/error.hpp"
+#include "storage/storage_client.hpp"
+
+namespace smartbotic::settings {
+
+// One row per change, so a surprising value can be traced back to who set it
+// and when - overwriting a later value must not erase the earlier record of
+// who touched it.
+struct SettingChange {
+    std::string key;
+    nlohmann::json value;   // the new override value; null when the change cleared it
+    bool cleared = false;
+    std::string changed_by;           // user id
+    std::string changed_by_username;  // for a readable audit trail without a join
+    int64_t changed_at = 0;           // ms since epoch
+
+    nlohmann::json toJson() const;
+};
+
+// Project-scoped collection holding only the keys that have been overridden.
+// Absent means "use the file default" - this store never holds a copy of the
+// file's own values, only the deltas from it.
+//
+// A live setting is read on every operation that consults it - a login, a
+// runner heartbeat sweep - so the whole override set is cached in memory and
+// refreshed only when a write happens, never queried from the database on a
+// per-lookup basis.
+class SettingsStore {
+public:
+    explicit SettingsStore(storage::StorageClient& storage);
+
+    // Effective override for one key, or nullopt if there isn't one. Served
+    // from the in-memory cache - no database round trip.
+    std::optional<nlohmann::json> get(const std::string& key) const;
+
+    // Every override currently set. Served from the cache.
+    std::unordered_map<std::string, nlohmann::json> getAll() const;
+
+    // Sets an override and appends an audit record, then refreshes the cache
+    // so the very next read - on any thread - sees the new value.
+    common::Result<void> setOverride(const std::string& key, const nlohmann::json& value,
+                                     const std::string& changed_by,
+                                     const std::string& changed_by_username);
+
+    // Clears an override, falling back to the file default again. A no-op
+    // (still recorded) if the key was not overridden.
+    common::Result<void> clearOverride(const std::string& key,
+                                       const std::string& changed_by,
+                                       const std::string& changed_by_username);
+
+    // Most recent changes first. Reads the database directly - this is for an
+    // admin looking something up after the fact, not a per-operation lookup.
+    common::Result<std::vector<SettingChange>> recentChanges(int limit = 100) const;
+
+private:
+    void reload();
+    nlohmann::json currentDocOrEmpty() const;
+
+    storage::StorageClient& storage_;
+    mutable std::mutex mutex_;
+    std::unordered_map<std::string, nlohmann::json> cache_;
+};
+
+} // namespace smartbotic::settings

+ 215 - 0
src/webserver/api/settings_controller.cpp

@@ -0,0 +1,215 @@
+#include "settings_controller.hpp"
+
+#include "logging/logger.hpp"
+#include "../settings/settings_registry.hpp"
+
+namespace smartbotic::webserver::api {
+
+// SettingDef and friends live directly in smartbotic::webserver (see
+// settings_registry.hpp) - not in a nested "settings" namespace, which would
+// collide with smartbotic::settings, the generic override-store library.
+
+SettingsController::SettingsController(settings::SettingsStore& store, auth::AuthMiddleware& middleware,
+                                       const WebServerServiceConfig& config)
+    : store_(store), middleware_(middleware), config_(config) {}
+
+void SettingsController::registerRoutes(httplib::Server& server) {
+    server.Get("/api/v1/settings", [this](const httplib::Request& req, httplib::Response& res) {
+        middleware_.requireRole(req, res, "admin", [this](auto& req, auto& res, auto& ctx) {
+            listSettings(req, res, ctx);
+        });
+    });
+
+    server.Put("/api/v1/settings", [this](const httplib::Request& req, httplib::Response& res) {
+        middleware_.requireRole(req, res, "admin", [this](auto& req, auto& res, auto& ctx) {
+            updateSettings(req, res, ctx);
+        });
+    });
+}
+
+namespace {
+
+nlohmann::json settingToJson(const SettingDef& def, const WebServerServiceConfig& config,
+                            const settings::SettingsStore& store) {
+    nlohmann::json entry{
+        {"key", def.key},
+        {"type", def.type},
+        {"classification", settingClassToString(def.klass)},
+        {"component", settingComponentToString(def.component)},
+        {"writable", def.klass == SettingClass::Live},
+        {"reason", def.reason},
+    };
+    if (!def.constraints.empty()) {
+        entry["constraints"] = def.constraints;
+    }
+
+    // A secret's value must never leave this process, in any form - not the
+    // live value, not the file default, not even whether it is currently
+    // overridden. Every other field above (classification, reason, ...)
+    // carries no secret material, so those are safe to include.
+    if (def.klass == SettingClass::Secret) {
+        return entry;
+    }
+
+    auto override_value = store.get(def.key);
+    if (override_value.has_value()) {
+        entry["value"] = *override_value;
+        entry["source"] = "override";
+    } else {
+        entry["value"] = def.default_value(config);
+        entry["source"] = "default";
+    }
+    return entry;
+}
+
+} // namespace
+
+void SettingsController::listSettings(const httplib::Request&, httplib::Response& res,
+                                      const auth::AuthContext&) {
+    nlohmann::json response;
+    response["settings"] = nlohmann::json::array();
+    for (const auto& def : allSettingDefs()) {
+        response["settings"].push_back(settingToJson(def, config_, store_));
+    }
+    sendJson(res, response);
+}
+
+void SettingsController::updateSettings(const httplib::Request& req, httplib::Response& res,
+                                        const auth::AuthContext& ctx) {
+    nlohmann::json body;
+    try {
+        body = nlohmann::json::parse(req.body);
+    } catch (const std::exception&) {
+        sendError(res, "Invalid request body: not valid JSON", 400);
+        return;
+    }
+
+    if (!body.is_object()) {
+        sendError(res, "Invalid request body: expected a JSON object", 400);
+        return;
+    }
+
+    auto sets = body.value("sets", nlohmann::json::object());
+    auto clears = body.value("clears", nlohmann::json::array());
+    if (!sets.is_object()) {
+        sendError(res, "'sets' must be an object of key to new value", 400);
+        return;
+    }
+    if (!clears.is_array()) {
+        sendError(res, "'clears' must be an array of keys", 400);
+        return;
+    }
+
+    // A body carrying neither is refused rather than answered with 200.
+    //
+    // The shape is `{"sets": {...}, "clears": [...]}`, and anything else -
+    // a caller that sent the key and value at the top level, a typo in
+    // "sets" - parses as valid JSON, matches neither field, and would
+    // otherwise be reported as success having changed nothing. A settings
+    // page saving into a mistyped payload would look like it was working.
+    if (sets.empty() && clears.empty()) {
+        sendError(res,
+                  "Nothing to do: send at least one key under 'sets' or 'clears'. "
+                  "The body is {\"sets\": {\"key\": value}, \"clears\": [\"key\"]}",
+                  400);
+        return;
+    }
+
+    // Validated up front, all of it, before anything is written - a request
+    // that rejects one key must not have already applied its neighbours.
+    nlohmann::json problems = nlohmann::json::array();
+
+    auto checkKnownAndLive = [&](const std::string& key) -> const SettingDef* {
+        const SettingDef* def = findSettingDef(key);
+        if (!def) {
+            problems.push_back({{"key", key}, {"reason", "unknown setting key"}});
+            return nullptr;
+        }
+        if (def->klass == SettingClass::Secret) {
+            problems.push_back({{"key", key},
+                                 {"reason", "this key is a secret and can never be set "
+                                            "or cleared through this API"}});
+            return nullptr;
+        }
+        if (def->klass == SettingClass::RestartRequired) {
+            problems.push_back({{"key", key},
+                                 {"reason", "this key requires a restart to change: " + def->reason}});
+            return nullptr;
+        }
+        return def;
+    };
+
+    for (auto it = sets.begin(); it != sets.end(); ++it) {
+        const SettingDef* def = checkKnownAndLive(it.key());
+        if (!def) continue;
+
+        if (!def->validate) continue;
+        auto validation_error = def->validate(it.value());
+        if (validation_error.has_value()) {
+            problems.push_back({{"key", it.key()}, {"reason", *validation_error}});
+        }
+    }
+
+    std::vector<std::string> clear_keys;
+    for (const auto& item : clears) {
+        if (!item.is_string()) {
+            problems.push_back({{"key", item.dump()}, {"reason", "clears entries must be strings"}});
+            continue;
+        }
+        const std::string key = item.get<std::string>();
+        if (checkKnownAndLive(key)) {
+            clear_keys.push_back(key);
+        }
+    }
+
+    if (!problems.empty()) {
+        sendError(res, "One or more settings were rejected; nothing was changed", 400, problems);
+        return;
+    }
+
+    for (auto it = sets.begin(); it != sets.end(); ++it) {
+        auto result = store_.setOverride(it.key(), it.value(), ctx.user_id, ctx.username);
+        if (result.failed()) {
+            LOG_ERROR("Failed to set override for '{}': {}", it.key(), result.error().message());
+            sendError(res, "Failed to save override for '" + it.key() + "': " +
+                          result.error().message(), 500);
+            return;
+        }
+        LOG_INFO("Setting '{}' overridden by {} ({})", it.key(), ctx.username, ctx.user_id);
+    }
+
+    for (const auto& key : clear_keys) {
+        auto result = store_.clearOverride(key, ctx.user_id, ctx.username);
+        if (result.failed()) {
+            LOG_ERROR("Failed to clear override for '{}': {}", key, result.error().message());
+            sendError(res, "Failed to clear override for '" + key + "': " +
+                          result.error().message(), 500);
+            return;
+        }
+        LOG_INFO("Setting '{}' override cleared by {} ({})", key, ctx.username, ctx.user_id);
+    }
+
+    nlohmann::json response;
+    response["settings"] = nlohmann::json::array();
+    for (const auto& def : allSettingDefs()) {
+        response["settings"].push_back(settingToJson(def, config_, store_));
+    }
+    sendJson(res, response);
+}
+
+void SettingsController::sendJson(httplib::Response& res, const nlohmann::json& data, int status) {
+    res.status = status;
+    res.set_content(data.dump(), "application/json");
+}
+
+void SettingsController::sendError(httplib::Response& res, const std::string& message, int status,
+                                   const nlohmann::json& details) {
+    res.status = status;
+    nlohmann::json body{{"error", message}};
+    if (!details.is_null() && !(details.is_array() && details.empty())) {
+        body["details"] = details;
+    }
+    res.set_content(body.dump(), "application/json");
+}
+
+} // namespace smartbotic::webserver::api

+ 37 - 0
src/webserver/api/settings_controller.hpp

@@ -0,0 +1,37 @@
+#pragma once
+
+#include <httplib.h>
+#include <nlohmann/json.hpp>
+
+#include "../auth/auth_middleware.hpp"
+#include "../webserver_service.hpp"
+#include "settings/settings_store.hpp"
+
+namespace smartbotic::webserver::api {
+
+// Admin-only view and control over the runtime settings registry: every
+// known key, its effective value, whether it can be changed without a
+// restart, and (for the ones that can) a way to set or clear an override.
+class SettingsController {
+public:
+    SettingsController(settings::SettingsStore& store, auth::AuthMiddleware& middleware,
+                       const WebServerServiceConfig& config);
+
+    void registerRoutes(httplib::Server& server);
+
+private:
+    void listSettings(const httplib::Request& req, httplib::Response& res,
+                      const auth::AuthContext& ctx);
+    void updateSettings(const httplib::Request& req, httplib::Response& res,
+                        const auth::AuthContext& ctx);
+
+    void sendJson(httplib::Response& res, const nlohmann::json& data, int status = 200);
+    void sendError(httplib::Response& res, const std::string& message, int status,
+                   const nlohmann::json& details = nlohmann::json());
+
+    settings::SettingsStore& store_;
+    auth::AuthMiddleware& middleware_;
+    const WebServerServiceConfig& config_;
+};
+
+} // namespace smartbotic::webserver::api

+ 12 - 4
src/webserver/auth/jwt_utils.cpp

@@ -38,20 +38,28 @@ TokenPayload TokenPayload::fromJson(const nlohmann::json& j) {
     return payload;
 }
 
-JwtUtils::JwtUtils(const Config& config)
-    : config_(config) {}
+JwtUtils::JwtUtils(const Config& config, settings::SettingsAccessor* settings)
+    : config_(config), settings_(settings) {}
 
 std::string JwtUtils::generateAccessToken(const std::string& user_id,
                                           const std::string& username,
                                           const std::string& role) {
     auto now = TimeUtils::nowSec();
 
+    // Asked for on every call, not read once at startup - an override to
+    // auth.access_token_lifetime_sec takes effect on the very next login or
+    // refresh, no restart.
+    int64_t lifetime_sec = config_.access_token_lifetime_sec;
+    if (settings_) {
+        lifetime_sec = settings_->getOr<int64_t>("auth.access_token_lifetime_sec", lifetime_sec);
+    }
+
     TokenPayload payload;
     payload.user_id = user_id;
     payload.username = username;
     payload.role = role;
     payload.issued_at = now;
-    payload.expires_at = now + config_.access_token_lifetime_sec;
+    payload.expires_at = now + lifetime_sec;
     payload.token_type = "access";
     // Same collision as the refresh token: two access tokens minted for the
     // same user in the same second would otherwise be identical too (e.g. a
@@ -71,7 +79,7 @@ std::string JwtUtils::generateRefreshToken(const std::string& user_id,
     payload.username = username;
     payload.role = role;
     payload.issued_at = now;
-    payload.expires_at = now + config_.refresh_token_lifetime_sec;
+    payload.expires_at = now + refreshTokenLifetimeSec();
     payload.token_type = "refresh";
     // A random UUID (backed by OpenSSL RAND_bytes, see common::UUID), not a
     // counter or a finer-grained timestamp - the goal is a value nobody can

+ 15 - 4
src/webserver/auth/jwt_utils.hpp

@@ -4,6 +4,7 @@
 #include <optional>
 #include <nlohmann/json.hpp>
 #include "common/error.hpp"
+#include "settings/settings_accessor.hpp"
 
 namespace smartbotic::webserver::auth {
 
@@ -40,12 +41,21 @@ public:
         std::string issuer = "smartbotic";
     };
 
-    explicit JwtUtils(const Config& config);
+    // `settings` is optional (nullptr for a JwtUtils built without a settings
+    // store, e.g. before one exists) - without it, both lifetimes behave
+    // exactly as before: fixed at the value captured from config at startup.
+    explicit JwtUtils(const Config& config, settings::SettingsAccessor* settings = nullptr);
 
-    // How long a refresh token is good for. A session record has to be kept at
-    // least as long as the token that points at it, or the token outlives its
-    // record and its holder is told the session does not exist.
+    // How long a refresh token is good for right now. A session record has to
+    // be kept at least as long as the token that points at it, or the token
+    // outlives its record and its holder is told the session does not exist.
+    // Reads the live override on every call rather than the value captured
+    // at startup, same as generateRefreshToken.
     [[nodiscard]] int64_t refreshTokenLifetimeSec() const {
+        if (settings_) {
+            return settings_->getOr<int64_t>("auth.refresh_token_lifetime_sec",
+                                             config_.refresh_token_lifetime_sec);
+        }
         return config_.refresh_token_lifetime_sec;
     }
 
@@ -80,6 +90,7 @@ private:
     bool verifySignature(const std::string& data, const std::string& signature);
 
     Config config_;
+    settings::SettingsAccessor* settings_ = nullptr;
 };
 
 } // namespace smartbotic::webserver::auth

+ 13 - 3
src/webserver/runners/load_balancer.cpp

@@ -23,8 +23,18 @@ std::string loadBalancingStrategyToString(LoadBalancingStrategy strategy) {
     }
 }
 
-LoadBalancer::LoadBalancer(RunnerRegistry& registry, const LoadBalancerConfig& config)
-    : registry_(registry), config_(config) {}
+LoadBalancer::LoadBalancer(RunnerRegistry& registry, const LoadBalancerConfig& config,
+                           settings::SettingsAccessor* settings)
+    : registry_(registry), config_(config), settings_(settings) {}
+
+LoadBalancingStrategy LoadBalancer::effectiveStrategy() const {
+    if (!settings_) {
+        return config_.strategy;
+    }
+    auto strategy_str = settings_->getOr<std::string>(
+        "runners.load_balancing", loadBalancingStrategyToString(config_.strategy));
+    return loadBalancingStrategyFromString(strategy_str);
+}
 
 std::shared_ptr<::grpc::Channel> LoadBalancer::createChannel(const std::string& address) const {
     ::grpc::ChannelArguments args;
@@ -69,7 +79,7 @@ std::optional<Runner> LoadBalancer::selectRunner(const std::string& required_nod
         return std::nullopt;
     }
 
-    switch (config_.strategy) {
+    switch (effectiveStrategy()) {
         case LoadBalancingStrategy::RoundRobin:
             return selectRoundRobin(available);
         case LoadBalancingStrategy::LeastConnections:

+ 11 - 1
src/webserver/runners/load_balancer.hpp

@@ -5,6 +5,7 @@
 #include <atomic>
 #include <memory>
 #include "runner_registry.hpp"
+#include "settings/settings_accessor.hpp"
 
 namespace grpc {
 class Channel;
@@ -49,7 +50,10 @@ struct LoadBalancerConfig {
 // Load balancer - selects runner for workflow execution
 class LoadBalancer {
 public:
-    LoadBalancer(RunnerRegistry& registry, const LoadBalancerConfig& config = {});
+    // `settings` is optional (nullptr keeps the strategy fixed at its
+    // startup value, as before).
+    LoadBalancer(RunnerRegistry& registry, const LoadBalancerConfig& config = {},
+                 settings::SettingsAccessor* settings = nullptr);
 
     // Select a runner for execution
     // Returns runner address or nullopt if no runner available
@@ -79,6 +83,11 @@ public:
     void setStrategy(LoadBalancingStrategy strategy) { config_.strategy = strategy; }
 
 private:
+    // Asked for on every selectRunner() call rather than read once at
+    // startup - an override to runners.load_balancing takes effect on the
+    // next execution dispatched, no restart.
+    LoadBalancingStrategy effectiveStrategy() const;
+
     std::optional<Runner> selectRoundRobin(const std::vector<Runner>& runners);
     std::optional<Runner> selectLeastConnections(const std::vector<Runner>& runners);
     std::optional<Runner> selectWeighted(const std::vector<Runner>& runners);
@@ -89,6 +98,7 @@ private:
 
     RunnerRegistry& registry_;
     LoadBalancerConfig config_;
+    settings::SettingsAccessor* settings_ = nullptr;
     std::atomic<size_t> round_robin_index_{0};
 };
 

+ 17 - 4
src/webserver/runners/runner_registry.cpp

@@ -105,8 +105,9 @@ Runner Runner::fromJson(const nlohmann::json& j) {
 }
 
 // RunnerRegistry
-RunnerRegistry::RunnerRegistry(storage::StorageClient& storage, const RunnerRegistryConfig& config)
-    : storage_(storage), config_(config) {
+RunnerRegistry::RunnerRegistry(storage::StorageClient& storage, const RunnerRegistryConfig& config,
+                               settings::SettingsAccessor* settings)
+    : storage_(storage), config_(config), settings_(settings) {
     loadRunners();
 }
 
@@ -280,6 +281,18 @@ void RunnerRegistry::cleanupLoop() {
 void RunnerRegistry::checkRunnerTimeouts() {
     auto now = TimeUtils::nowMs();
 
+    // Asked for on every sweep rather than read once at startup - an override
+    // to runners.heartbeat_timeout_sec or runners.offline_removal_sec takes
+    // effect on the next tick of this loop, no restart.
+    int64_t heartbeat_timeout_sec = config_.heartbeat_timeout_sec;
+    int64_t offline_removal_sec = config_.offline_removal_sec;
+    if (settings_) {
+        heartbeat_timeout_sec = settings_->getOr<int64_t>("runners.heartbeat_timeout_sec",
+                                                           heartbeat_timeout_sec);
+        offline_removal_sec = settings_->getOr<int64_t>("runners.offline_removal_sec",
+                                                         offline_removal_sec);
+    }
+
     std::unique_lock lock(mutex_);
 
     std::vector<std::string> to_remove;
@@ -289,14 +302,14 @@ void RunnerRegistry::checkRunnerTimeouts() {
 
         // Mark as offline if heartbeat timed out
         if (runner.status != RunnerStatus::Offline &&
-            since_heartbeat > config_.heartbeat_timeout_sec * 1000) {
+            since_heartbeat > heartbeat_timeout_sec * 1000) {
             runner.status = RunnerStatus::Offline;
             LOG_WARN("Runner {} marked offline (no heartbeat)", id);
         }
 
         // Remove if offline for too long
         if (runner.status == RunnerStatus::Offline &&
-            since_heartbeat > config_.offline_removal_sec * 1000) {
+            since_heartbeat > offline_removal_sec * 1000) {
             to_remove.push_back(id);
         }
     }

+ 6 - 1
src/webserver/runners/runner_registry.hpp

@@ -11,6 +11,7 @@
 #include <nlohmann/json.hpp>
 #include "common/error.hpp"
 #include "storage/storage_client.hpp"
+#include "settings/settings_accessor.hpp"
 
 namespace smartbotic::webserver::runners {
 
@@ -75,7 +76,10 @@ struct RunnerRegistryConfig {
 // Runner registry - tracks registered runners
 class RunnerRegistry {
 public:
-    RunnerRegistry(storage::StorageClient& storage, const RunnerRegistryConfig& config = {});
+    // `settings` is optional (nullptr keeps heartbeat_timeout_sec and
+    // offline_removal_sec fixed at their startup values, as before).
+    RunnerRegistry(storage::StorageClient& storage, const RunnerRegistryConfig& config = {},
+                   settings::SettingsAccessor* settings = nullptr);
     ~RunnerRegistry();
 
     // Start/stop background cleanup
@@ -110,6 +114,7 @@ private:
 
     storage::StorageClient& storage_;
     RunnerRegistryConfig config_;
+    settings::SettingsAccessor* settings_ = nullptr;
 
     std::unordered_map<std::string, Runner> runners_;
     mutable std::shared_mutex mutex_;

+ 344 - 0
src/webserver/settings/settings_registry.cpp

@@ -0,0 +1,344 @@
+#include "settings_registry.hpp"
+
+#include "../runners/load_balancer.hpp"
+
+namespace smartbotic::webserver {
+
+std::string settingClassToString(SettingClass klass) {
+    switch (klass) {
+        case SettingClass::Live: return "live";
+        case SettingClass::RestartRequired: return "restart-required";
+        case SettingClass::Secret: return "secret";
+    }
+    return "unknown";
+}
+
+std::string settingComponentToString(SettingComponent component) {
+    switch (component) {
+        case SettingComponent::WebServer: return "webserver";
+        case SettingComponent::Runner: return "runner";
+    }
+    return "unknown";
+}
+
+namespace {
+
+// A webserver-side override of a runner-owned key is invisible to a running
+// runner: the runner reads its own runner.json at its own startup, and
+// nothing today ships settings from the webserver's database over to it.
+// Repeating that sentence in each runner-owned entry's reason would be pure
+// noise, so it lives here once.
+constexpr const char* kRunnerNotPropagated =
+    " A webserver-side override would not reach a running runner - nothing "
+    "propagates settings from the webserver's database to the runner "
+    "process - so this stays restart-required until that propagation "
+    "exists.";
+
+std::optional<std::string> validatePositiveIntRange(const nlohmann::json& value, int64_t min_value,
+                                                     int64_t max_value, const char* unit) {
+    if (!value.is_number_integer()) {
+        return std::string("must be an integer number of ") + unit;
+    }
+    int64_t v = value.get<int64_t>();
+    if (v < min_value || v > max_value) {
+        return "must be between " + std::to_string(min_value) + " and " +
+               std::to_string(max_value) + " " + unit + " (got " + std::to_string(v) + ")";
+    }
+    return std::nullopt;
+}
+
+std::optional<std::string> validateLoadBalancingStrategy(const nlohmann::json& value) {
+    if (!value.is_string()) {
+        return std::string("must be a string, one of: round-robin, least-connections, "
+                            "weighted, random");
+    }
+    const std::string s = value.get<std::string>();
+    if (s != "round-robin" && s != "least-connections" && s != "weighted" && s != "random") {
+        return "must be one of: round-robin, least-connections, weighted, random (got '" + s + "')";
+    }
+    return std::nullopt;
+}
+
+std::vector<SettingDef> buildSettingDefs() {
+    std::vector<SettingDef> defs;
+
+    // ---- WebServer-owned, structural (RestartRequired) ----
+
+    defs.push_back({
+        "http_port", "int", SettingClass::RestartRequired, SettingComponent::WebServer,
+        "Bound once when HttpServer is constructed; changing it would mean rebinding the "
+        "listening socket.",
+        "",
+        [](const WebServerServiceConfig& c) { return c.http_port; },
+        nullptr,
+    });
+    defs.push_back({
+        "node_sync_port", "int", SettingClass::RestartRequired, SettingComponent::WebServer,
+        "Bound once when the NodeSync gRPC server is constructed.",
+        "",
+        [](const WebServerServiceConfig& c) { return c.node_sync_port; },
+        nullptr,
+    });
+    defs.push_back({
+        "credential_service_port", "int", SettingClass::RestartRequired, SettingComponent::WebServer,
+        "Bound once when the Credential gRPC server is constructed.",
+        "",
+        [](const WebServerServiceConfig& c) { return c.credential_service_port; },
+        nullptr,
+    });
+    defs.push_back({
+        "static_files_path", "string", SettingClass::RestartRequired, SettingComponent::WebServer,
+        "Read once to mount the static file directory when HttpServer is constructed.",
+        "",
+        [](const WebServerServiceConfig& c) { return c.static_files_path; },
+        nullptr,
+    });
+    defs.push_back({
+        "database_address", "string", SettingClass::RestartRequired, SettingComponent::WebServer,
+        "Used once to open the StorageClient connection at construction - the settings "
+        "store itself lives behind that connection, so this key cannot safely gate its "
+        "own storage.",
+        "",
+        [](const WebServerServiceConfig& c) { return c.database_address; },
+        nullptr,
+    });
+    defs.push_back({
+        "database_project", "string", SettingClass::RestartRequired, SettingComponent::WebServer,
+        "Used once to open the StorageClient connection at construction, same as "
+        "database_address.",
+        "",
+        [](const WebServerServiceConfig& c) { return c.database_project; },
+        nullptr,
+    });
+    defs.push_back({
+        "server.max_upload_mb", "int", SettingClass::RestartRequired, SettingComponent::WebServer,
+        "Applied when the httplib server is constructed - the body-size cap is baked into "
+        "HttpServerConfig at that point.",
+        "",
+        [](const WebServerServiceConfig& c) { return c.max_upload_mb; },
+        nullptr,
+    });
+    defs.push_back({
+        "server.form_dispatch_threads", "int", SettingClass::RestartRequired, SettingComponent::WebServer,
+        "WebhookController's dispatch thread pool is built once, when routes are set up.",
+        "",
+        [](const WebServerServiceConfig& c) { return c.form_dispatch_threads; },
+        nullptr,
+    });
+    defs.push_back({
+        "server.form_dispatch_queue_capacity", "int", SettingClass::RestartRequired, SettingComponent::WebServer,
+        "Bounds the same dispatch pool as server.form_dispatch_threads, built once.",
+        "",
+        [](const WebServerServiceConfig& c) { return c.form_dispatch_queue_capacity; },
+        nullptr,
+    });
+    defs.push_back({
+        "credentials.pbkdf2_iterations", "int", SettingClass::RestartRequired, SettingComponent::WebServer,
+        "Baked into the AesGcm key-derivation instance at CredentialStore construction; "
+        "changing it without re-deriving already-encrypted credentials would make them "
+        "silently undecryptable rather than fail loudly.",
+        "",
+        [](const WebServerServiceConfig& c) { return c.credentials_config.pbkdf2_iterations; },
+        nullptr,
+    });
+
+    // ---- WebServer-owned, secrets ----
+
+    defs.push_back({
+        "auth.jwt_secret", "string", SettingClass::Secret, SettingComponent::WebServer,
+        "Signs every issued token. Rotating it at runtime would invalidate every "
+        "outstanding session without warning, and its value must never be exposed over "
+        "the API.",
+        "",
+        [](const WebServerServiceConfig&) { return nlohmann::json(); },
+        nullptr,
+    });
+    defs.push_back({
+        "credentials.master_key", "string", SettingClass::Secret, SettingComponent::WebServer,
+        "Encrypts and decrypts every stored credential. Rotating it at runtime without "
+        "re-encrypting existing records would make them permanently undecryptable, and "
+        "its value must never be exposed over the API.",
+        "",
+        [](const WebServerServiceConfig&) { return nlohmann::json(); },
+        nullptr,
+    });
+
+    // ---- WebServer-owned, live ----
+
+    defs.push_back({
+        "runners.heartbeat_timeout_sec", "int", SettingClass::Live, SettingComponent::WebServer,
+        "Consulted on every RunnerRegistry cleanup tick, not captured once at startup.",
+        "1 to 3600 seconds",
+        [](const WebServerServiceConfig& c) { return c.runner_config.heartbeat_timeout_sec; },
+        [](const nlohmann::json& v) { return validatePositiveIntRange(v, 1, 3600, "seconds"); },
+    });
+    defs.push_back({
+        "runners.offline_removal_sec", "int", SettingClass::Live, SettingComponent::WebServer,
+        "Consulted on every RunnerRegistry cleanup tick, not captured once at startup.",
+        "1 to 86400 seconds",
+        [](const WebServerServiceConfig& c) { return c.runner_config.offline_removal_sec; },
+        [](const nlohmann::json& v) { return validatePositiveIntRange(v, 1, 86400, "seconds"); },
+    });
+    defs.push_back({
+        "runners.load_balancing", "string", SettingClass::Live, SettingComponent::WebServer,
+        "Consulted on every LoadBalancer::selectRunner() call.",
+        "one of: round-robin, least-connections, weighted, random",
+        [](const WebServerServiceConfig& c) {
+            return runners::loadBalancingStrategyToString(c.load_balancer_config.strategy);
+        },
+        validateLoadBalancingStrategy,
+    });
+    defs.push_back({
+        "auth.access_token_lifetime_sec", "int64", SettingClass::Live, SettingComponent::WebServer,
+        "Read on every JwtUtils::generateAccessToken() call.",
+        "30 to 86400 seconds",
+        [](const WebServerServiceConfig& c) { return c.jwt_config.access_token_lifetime_sec; },
+        [](const nlohmann::json& v) { return validatePositiveIntRange(v, 30, 86400, "seconds"); },
+    });
+    defs.push_back({
+        "auth.refresh_token_lifetime_sec", "int64", SettingClass::Live, SettingComponent::WebServer,
+        "Read on every JwtUtils::generateRefreshToken() call. This key was present in "
+        "config/webserver.json but, before this change, was never read via cfg.getOr - "
+        "the file value was silently ignored and the struct default (86400s) always won. "
+        "Fixed alongside adding the live accessor.",
+        "60 to 2592000 seconds",
+        [](const WebServerServiceConfig& c) { return c.jwt_config.refresh_token_lifetime_sec; },
+        [](const nlohmann::json& v) { return validatePositiveIntRange(v, 60, 2592000, "seconds"); },
+    });
+
+    // ---- Runner-owned: mechanically per-operation inside the runner, but
+    // restart-required from the webserver's point of view because nothing
+    // propagates an override across the process boundary. ----
+
+    defs.push_back({
+        "grpc_port", "int", SettingClass::RestartRequired, SettingComponent::Runner,
+        std::string("Bound once when the runner's own gRPC server starts.") + kRunnerNotPropagated,
+        "",
+        [](const WebServerServiceConfig&) { return 9003; },
+        nullptr,
+    });
+    defs.push_back({
+        "runner_id", "string", SettingClass::RestartRequired, SettingComponent::Runner,
+        std::string("Identifies this runner instance at registration; changing it mid-run "
+                     "would fork its identity from its own history.") + kRunnerNotPropagated,
+        "",
+        [](const WebServerServiceConfig&) { return "runner-1"; },
+        nullptr,
+    });
+    defs.push_back({
+        "webserver_address", "string", SettingClass::RestartRequired, SettingComponent::Runner,
+        std::string("Used once to open the channel back to the webserver at construction.") +
+            kRunnerNotPropagated,
+        "",
+        [](const WebServerServiceConfig&) { return "localhost:8080"; },
+        nullptr,
+    });
+    defs.push_back({
+        "node_sync_address", "string", SettingClass::RestartRequired, SettingComponent::Runner,
+        std::string("Used once to open the NodeSync channel.") + kRunnerNotPropagated,
+        "",
+        [](const WebServerServiceConfig&) { return "localhost:9002"; },
+        nullptr,
+    });
+    defs.push_back({
+        "credential_service_address", "string", SettingClass::RestartRequired, SettingComponent::Runner,
+        std::string("Used once to open the credential-service channel.") + kRunnerNotPropagated,
+        "",
+        [](const WebServerServiceConfig&) { return "localhost:9003"; },
+        nullptr,
+    });
+    defs.push_back({
+        "runner.database_address", "string", SettingClass::RestartRequired, SettingComponent::Runner,
+        std::string("Used once to open the runner's own StorageClient connection. Listed "
+                     "under a runner.-prefixed name here because the webserver has its own "
+                     "database_address key above; the two are read from different config "
+                     "files by different processes and happen to share a bare name in "
+                     "runner.json.") + kRunnerNotPropagated,
+        "",
+        [](const WebServerServiceConfig&) { return "localhost:9004"; },
+        nullptr,
+    });
+    defs.push_back({
+        "runner.database_project", "string", SettingClass::RestartRequired, SettingComponent::Runner,
+        std::string("Used once to open the runner's own StorageClient connection, same "
+                     "naming note as runner.database_address.") + kRunnerNotPropagated,
+        "",
+        [](const WebServerServiceConfig&) { return "smartbotic-automation"; },
+        nullptr,
+    });
+    defs.push_back({
+        "max_message_size_mb", "int", SettingClass::RestartRequired, SettingComponent::Runner,
+        std::string("Sizes the runner's own gRPC channels and server limits at "
+                     "construction.") + kRunnerNotPropagated,
+        "",
+        [](const WebServerServiceConfig&) { return 64; },
+        nullptr,
+    });
+    defs.push_back({
+        "node_sync.enabled", "bool", SettingClass::RestartRequired, SettingComponent::Runner,
+        std::string("Decided once at startup, to choose whether the node-sync client "
+                     "thread is started at all.") + kRunnerNotPropagated,
+        "",
+        [](const WebServerServiceConfig&) { return true; },
+        nullptr,
+    });
+    defs.push_back({
+        "node_sync.reconnect_interval_ms", "int", SettingClass::RestartRequired, SettingComponent::Runner,
+        std::string("Consumed per reconnect attempt inside the runner process, which "
+                     "makes it mechanically per-operation there - but it is the runner, "
+                     "not the webserver, that reads it.") + kRunnerNotPropagated,
+        "",
+        [](const WebServerServiceConfig&) { return 5000; },
+        nullptr,
+    });
+    defs.push_back({
+        "registration.heartbeat_interval_sec", "int", SettingClass::RestartRequired, SettingComponent::Runner,
+        std::string("Governs the interval of the runner's own heartbeat loop; read once "
+                     "into that loop's timer at startup.") + kRunnerNotPropagated,
+        "",
+        [](const WebServerServiceConfig&) { return 10; },
+        nullptr,
+    });
+    defs.push_back({
+        "registration.max_concurrent_executions", "int", SettingClass::RestartRequired, SettingComponent::Runner,
+        std::string("Reported to the webserver at registration and consulted by the "
+                     "runner's own admission check.") + kRunnerNotPropagated,
+        "",
+        [](const WebServerServiceConfig&) { return 10; },
+        nullptr,
+    });
+    defs.push_back({
+        "execution.default_timeout_ms", "int", SettingClass::RestartRequired, SettingComponent::Runner,
+        std::string("Read by the workflow engine inside the runner process.") + kRunnerNotPropagated,
+        "",
+        [](const WebServerServiceConfig&) { return 60000; },
+        nullptr,
+    });
+    defs.push_back({
+        "execution.max_memory_per_script_mb", "int", SettingClass::RestartRequired, SettingComponent::Runner,
+        std::string("Read by the script engine inside the runner process.") + kRunnerNotPropagated,
+        "",
+        [](const WebServerServiceConfig&) { return 64; },
+        nullptr,
+    });
+
+    return defs;
+}
+
+} // namespace
+
+const std::vector<SettingDef>& allSettingDefs() {
+    static const std::vector<SettingDef> defs = buildSettingDefs();
+    return defs;
+}
+
+const SettingDef* findSettingDef(const std::string& key) {
+    for (const auto& def : allSettingDefs()) {
+        if (def.key == key) {
+            return &def;
+        }
+    }
+    return nullptr;
+}
+
+} // namespace smartbotic::webserver

+ 67 - 0
src/webserver/settings/settings_registry.hpp

@@ -0,0 +1,67 @@
+#pragma once
+
+#include <functional>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include <nlohmann/json.hpp>
+
+#include "../webserver_service.hpp"
+
+namespace smartbotic::webserver {
+
+// How a key may be changed while the process is running.
+enum class SettingClass {
+    // Consulted at the point of use, every time - an override takes effect
+    // on the next operation, no restart.
+    Live,
+    // Consumed once, at construction or startup, to build something that is
+    // not rebuilt afterwards (a listening socket, a thread pool, a crypto
+    // engine derived from a key). Listed and readable, never writable.
+    RestartRequired,
+    // Same as RestartRequired, plus: its value must never appear in an API
+    // response, in any form.
+    Secret,
+};
+
+std::string settingClassToString(SettingClass klass);
+
+// Which process actually reads the key. A webserver-side override of a
+// runner-owned key is invisible to a running runner - nothing propagates
+// settings across that boundary yet - so every runner-owned key is
+// RestartRequired regardless of how often the runner itself consults it.
+enum class SettingComponent { WebServer, Runner };
+
+std::string settingComponentToString(SettingComponent component);
+
+struct SettingDef {
+    std::string key;
+    std::string type;  // "int" | "int64" | "double" | "string" | "bool"
+    SettingClass klass;
+    SettingComponent component;
+    std::string reason;
+    std::string constraints;  // human-readable bounds, empty when none apply
+
+    // The value in effect when nothing has overridden it, as this webserver
+    // process actually resolved it (its own loaded config file, env vars
+    // included) for a WebServer-owned key; the literal fallback baked into
+    // the owning getOr() call for a Runner-owned key, since this process
+    // never loads runner.json.
+    std::function<nlohmann::json(const WebServerServiceConfig&)> default_value;
+
+    // Only meaningful for Live keys - RestartRequired and Secret keys are
+    // never accepted by PUT regardless of what this returns. Returns an
+    // error message (stating the bound that was violated) or nullopt if the
+    // value is acceptable.
+    std::function<std::optional<std::string>(const nlohmann::json&)> validate;
+};
+
+// Every settings key known to the system, webserver- and runner-owned alike.
+// This is the single source of truth the API is built from, so the frontend
+// never has to hardcode its own copy of the classification.
+const std::vector<SettingDef>& allSettingDefs();
+
+const SettingDef* findSettingDef(const std::string& key);
+
+} // namespace smartbotic::webserver

+ 22 - 4
src/webserver/webserver_service.cpp

@@ -12,6 +12,7 @@
 #include "api/file_controller.hpp"
 #include "api/proxy_controller.hpp"
 #include "api/credential_controller.hpp"
+#include "api/settings_controller.hpp"
 #include "nodes/node_store.hpp"
 #include "grpc/node_sync_service.hpp"
 #include "grpc/credential_service.hpp"
@@ -55,8 +56,14 @@ WebServerService::WebServerService(const WebServerServiceConfig& config)
     storage_config.project = config_.database_project;
     storage_ = std::make_unique<storage::StorageClient>(storage_config);
 
+    // Runtime settings: overrides live in the database, read at the point of
+    // use. Built right after storage_ so every component below that takes a
+    // live setting can be handed a working accessor rather than a null one.
+    settings_store_ = std::make_unique<settings::SettingsStore>(*storage_);
+    settings_ = std::make_unique<settings::SettingsAccessor>(*settings_store_);
+
     // Initialize JWT
-    jwt_ = std::make_unique<auth::JwtUtils>(config_.jwt_config);
+    jwt_ = std::make_unique<auth::JwtUtils>(config_.jwt_config, settings_.get());
 
     // Initialize auth store
     auth_store_ = std::make_unique<auth::AuthStore>(*storage_, *jwt_);
@@ -120,11 +127,12 @@ WebServerService::WebServerService(const WebServerServiceConfig& config)
     auth_middleware_ = std::make_unique<auth::AuthMiddleware>(*jwt_, *auth_store_);
 
     // Initialize runner registry
-    runner_registry_ = std::make_unique<runners::RunnerRegistry>(*storage_, config_.runner_config);
+    runner_registry_ = std::make_unique<runners::RunnerRegistry>(
+        *storage_, config_.runner_config, settings_.get());
 
     // Initialize load balancer
-    load_balancer_ = std::make_unique<runners::LoadBalancer>(*runner_registry_,
-                                                              config_.load_balancer_config);
+    load_balancer_ = std::make_unique<runners::LoadBalancer>(
+        *runner_registry_, config_.load_balancer_config, settings_.get());
 
     // Initialize node store
     node_store_ = std::make_unique<nodes::NodeStore>(*storage_);
@@ -254,6 +262,12 @@ WebServerServiceConfig WebServerService::loadConfig(const std::filesystem::path&
                                                            "dev-secret-change-in-production");
         config.jwt_config.access_token_lifetime_sec =
             cfg.getOr<int>("auth.access_token_lifetime_sec", 900);
+        // Was declared in JwtUtils::Config and present in webserver.json, but
+        // never actually read here - the file value was silently ignored and
+        // the struct default (86400s) always won. Fixed as part of wiring
+        // this key into the live settings accessor.
+        config.jwt_config.refresh_token_lifetime_sec =
+            cfg.getOr<int64_t>("auth.refresh_token_lifetime_sec", 86400);
 
         // Credentials config
         config.credentials_config.master_key = cfg.getOr<std::string>("credentials.master_key",
@@ -459,6 +473,10 @@ void WebServerService::setupRoutes() {
         *credential_store_, *access_, *storage_, *auth_middleware_);
     credential_ctrl_->registerRoutes(server);
 
+    settings_ctrl_ = std::make_unique<api::SettingsController>(
+        *settings_store_, *auth_middleware_, config_);
+    settings_ctrl_->registerRoutes(server);
+
     LOG_INFO("API routes registered");
 }
 

+ 9 - 0
src/webserver/webserver_service.hpp

@@ -17,6 +17,8 @@
 #include "api/project_controller.hpp"
 #include "auth/access.hpp"
 #include "retention/retention_service.hpp"
+#include "settings/settings_store.hpp"
+#include "settings/settings_accessor.hpp"
 
 namespace smartbotic::webserver::nodes {
     class NodeStore;
@@ -44,6 +46,7 @@ namespace smartbotic::webserver::api {
     class WebhookController;
     class DatabaseController;
     class CredentialController;
+    class SettingsController;
 }
 
 namespace smartbotic::webserver {
@@ -129,6 +132,11 @@ private:
 
     // Core components
     std::unique_ptr<storage::StorageClient> storage_;
+    // Built right after storage_ and before anything that reads a live
+    // setting - a live accessor handed out before this exists would be a
+    // reference to nothing.
+    std::unique_ptr<settings::SettingsStore> settings_store_;
+    std::unique_ptr<settings::SettingsAccessor> settings_;
     std::unique_ptr<auth::JwtUtils> jwt_;
     std::unique_ptr<auth::AuthStore> auth_store_;
     std::unique_ptr<auth::AuthMiddleware> auth_middleware_;
@@ -169,6 +177,7 @@ private:
     std::unique_ptr<api::WebhookController> webhook_ctrl_;
     std::unique_ptr<api::DatabaseController> database_ctrl_;
     std::unique_ptr<api::CredentialController> credential_ctrl_;
+    std::unique_ptr<api::SettingsController> settings_ctrl_;
 };
 
 } // namespace smartbotic::webserver