Преглед изворни кода

feat: add script automation system with event and cron triggers

- Add ScriptService for script CRUD, execution tracking and triggers
- Add runner automation API (logging, key-value storage callbacks)
- Add script REST routes under /api/workspaces/:wid/scripts and executions
- Add Script and ScriptExecution entity types to EventManager
- Add script permission scope (create, read/write/delete all/own, execute)
- Expose DocumentService JSON/proto conversion helpers publicly
- Add Scripts list and ScriptEdit pages with trigger, cron and execution panels
- Add script permission helpers to usePermissions and permission UI
fszontagh пре 2 недеља
родитељ
комит
7e87350928
29 измењених фајлова са 5107 додато и 24 уклоњено
  1. 1 0
      runner/CMakeLists.txt
  2. 173 0
      runner/include/smartbotic/runner/automation_api.hpp
  3. 367 0
      runner/src/automation_api.cpp
  4. 2 0
      webserver/CMakeLists.txt
  5. 11 9
      webserver/include/smartbotic/webserver/document_service.hpp
  6. 11 1
      webserver/include/smartbotic/webserver/event_manager.hpp
  7. 16 0
      webserver/include/smartbotic/webserver/http_server.hpp
  8. 32 0
      webserver/include/smartbotic/webserver/permissions.hpp
  9. 314 0
      webserver/include/smartbotic/webserver/script_service.hpp
  10. 2 0
      webserver/src/archive_service.cpp
  11. 40 10
      webserver/src/event_manager.cpp
  12. 669 0
      webserver/src/http_server.cpp
  13. 24 0
      webserver/src/permissions.cpp
  14. 1200 0
      webserver/src/script_service.cpp
  15. 5 0
      webui/src/App.tsx
  16. 106 0
      webui/src/api/scripts.ts
  17. 2 1
      webui/src/components/PermissionEditor.tsx
  18. 11 0
      webui/src/components/PermissionSummary.tsx
  19. 195 0
      webui/src/components/ScriptEditor/ConfigPanel.tsx
  20. 195 0
      webui/src/components/ScriptEditor/CronExpressionInput.tsx
  21. 312 0
      webui/src/components/ScriptEditor/ExecutionPanel.tsx
  22. 251 0
      webui/src/components/ScriptEditor/TriggerEditor.tsx
  23. 7 0
      webui/src/components/ScriptEditor/index.tsx
  24. 15 0
      webui/src/components/layout/Sidebar.tsx
  25. 81 1
      webui/src/hooks/usePermissions.ts
  26. 493 0
      webui/src/pages/ScriptEdit.tsx
  27. 411 0
      webui/src/pages/Scripts.tsx
  28. 8 2
      webui/src/types/index.ts
  29. 153 0
      webui/src/types/scripts.ts

+ 1 - 0
runner/CMakeLists.txt

@@ -16,6 +16,7 @@ add_library(smartbotic_runner STATIC
     src/js_engine.cpp
     src/js_api.cpp
     src/template_parser.cpp
+    src/automation_api.cpp
 )
 
 target_include_directories(smartbotic_runner

+ 173 - 0
runner/include/smartbotic/runner/automation_api.hpp

@@ -0,0 +1,173 @@
+#pragma once
+
+#include <functional>
+#include <map>
+#include <string>
+#include <vector>
+
+namespace smartbotic::runner {
+
+/// Log levels for script logging
+enum class LogLevel {
+    Debug,
+    Info,
+    Warn,
+    Error
+};
+
+/// Log entry from script execution
+struct LogEntry {
+    LogLevel level;
+    std::string message;
+    std::string timestamp;
+};
+
+/// Callback for logging
+using LogCallback = std::function<void(LogLevel level, const std::string& message)>;
+
+/// Storage callbacks for script key-value storage
+struct StorageCallbacks {
+    /// Get value by key
+    std::function<std::string(const std::string& script_id, const std::string& key)> get;
+
+    /// Set value with optional TTL in seconds
+    std::function<bool(const std::string& script_id, const std::string& key,
+                       const std::string& value, int ttl_seconds)> set;
+
+    /// Delete a key
+    std::function<bool(const std::string& script_id, const std::string& key)> del;
+
+    /// Check if key exists
+    std::function<bool(const std::string& script_id, const std::string& key)> exists;
+
+    /// List keys with optional prefix
+    std::function<std::vector<std::string>(const std::string& script_id,
+                                           const std::string& prefix)> list;
+
+    /// Count keys with optional prefix
+    std::function<int64_t(const std::string& script_id, const std::string& prefix)> count;
+};
+
+/// HTTP response for script HTTP calls
+struct HttpResponse {
+    int status_code = 0;
+    std::string body;
+    std::map<std::string, std::string> headers;
+    std::string error;
+};
+
+/// HTTP request options
+struct HttpRequestOptions {
+    std::map<std::string, std::string> headers;
+    int timeout_ms = 5000;
+};
+
+/// Callback for HTTP requests
+using HttpCallback = std::function<HttpResponse(
+    const std::string& method,
+    const std::string& url,
+    const std::string& body,
+    const HttpRequestOptions& options)>;
+
+/// Document operation callbacks for write operations
+struct DocumentCallbacks {
+    /// Create a document
+    std::function<std::string(const std::string& collection,
+                               const std::string& data_json)> create;
+
+    /// Update a document
+    std::function<bool(const std::string& collection,
+                       const std::string& id,
+                       const std::string& data_json)> update;
+
+    /// Delete a document
+    std::function<bool(const std::string& collection,
+                       const std::string& id)> del;
+};
+
+/// Configuration for automation APIs
+struct AutomationApiConfig {
+    std::string script_id;       // ID of the executing script
+    std::string workspace_id;    // Workspace context
+    bool allow_network = false;  // Allow HTTP requests
+    bool allow_storage = true;   // Allow key-value storage
+    std::vector<std::string> allowed_collections;  // Collections for document ops
+};
+
+/// Automation API provider for extended JavaScript functions
+class AutomationApi {
+public:
+    AutomationApi() = default;
+    ~AutomationApi() = default;
+
+    /// Set the configuration
+    void SetConfig(const AutomationApiConfig& config);
+
+    /// Set the log callback
+    void SetLogCallback(LogCallback callback);
+
+    /// Set storage callbacks
+    void SetStorageCallbacks(const StorageCallbacks& callbacks);
+
+    /// Set HTTP callback (for network requests)
+    void SetHttpCallback(HttpCallback callback);
+
+    /// Set document write callbacks
+    void SetDocumentCallbacks(const DocumentCallbacks& callbacks);
+
+    /// Get collected logs
+    [[nodiscard]] auto GetLogs() const -> const std::vector<LogEntry>&;
+
+    /// Clear collected logs
+    void ClearLogs();
+
+    /// Generate JavaScript code to inject APIs into global scope
+    /// This should be executed before the user script
+    [[nodiscard]] auto GenerateApiCode() const -> std::string;
+
+    // === API Implementation (called from JS engine) ===
+
+    /// Log a message
+    void Log(LogLevel level, const std::string& message);
+
+    /// Storage operations
+    [[nodiscard]] auto StorageGet(const std::string& key) -> std::string;
+    auto StorageSet(const std::string& key, const std::string& value, int ttl = 0) -> bool;
+    auto StorageDel(const std::string& key) -> bool;
+    [[nodiscard]] auto StorageExists(const std::string& key) -> bool;
+    [[nodiscard]] auto StorageList(const std::string& prefix) -> std::vector<std::string>;
+    [[nodiscard]] auto StorageCount(const std::string& prefix) -> int64_t;
+
+    /// HTTP operations
+    [[nodiscard]] auto HttpGet(const std::string& url,
+                               const HttpRequestOptions& options = {}) -> HttpResponse;
+    [[nodiscard]] auto HttpPost(const std::string& url, const std::string& body,
+                                const HttpRequestOptions& options = {}) -> HttpResponse;
+    [[nodiscard]] auto HttpPut(const std::string& url, const std::string& body,
+                               const HttpRequestOptions& options = {}) -> HttpResponse;
+    [[nodiscard]] auto HttpDelete(const std::string& url,
+                                  const HttpRequestOptions& options = {}) -> HttpResponse;
+
+    /// Document write operations
+    [[nodiscard]] auto DocCreate(const std::string& collection,
+                                  const std::string& data) -> std::string;
+    auto DocUpdate(const std::string& collection, const std::string& id,
+                   const std::string& data) -> bool;
+    auto DocDelete(const std::string& collection, const std::string& id) -> bool;
+
+private:
+    AutomationApiConfig config_;
+    LogCallback log_callback_;
+    StorageCallbacks storage_callbacks_;
+    HttpCallback http_callback_;
+    DocumentCallbacks document_callbacks_;
+    std::vector<LogEntry> logs_;
+
+    /// Check if collection access is allowed
+    [[nodiscard]] auto IsCollectionAllowed(const std::string& collection) const -> bool;
+
+    /// Get current timestamp as ISO string
+    [[nodiscard]] static auto GetTimestamp() -> std::string;
+};
+
+}  // namespace smartbotic::runner

+ 367 - 0
runner/src/automation_api.cpp

@@ -0,0 +1,367 @@
+#include "smartbotic/runner/automation_api.hpp"
+
+#include <algorithm>
+#include <chrono>
+#include <iomanip>
+#include <sstream>
+
+namespace smartbotic::runner {
+
+void AutomationApi::SetConfig(const AutomationApiConfig& config) {
+    config_ = config;
+}
+
+void AutomationApi::SetLogCallback(LogCallback callback) {
+    log_callback_ = std::move(callback);
+}
+
+void AutomationApi::SetStorageCallbacks(const StorageCallbacks& callbacks) {
+    storage_callbacks_ = callbacks;
+}
+
+void AutomationApi::SetHttpCallback(HttpCallback callback) {
+    http_callback_ = std::move(callback);
+}
+
+void AutomationApi::SetDocumentCallbacks(const DocumentCallbacks& callbacks) {
+    document_callbacks_ = callbacks;
+}
+
+auto AutomationApi::GetLogs() const -> const std::vector<LogEntry>& {
+    return logs_;
+}
+
+void AutomationApi::ClearLogs() {
+    logs_.clear();
+}
+
+auto AutomationApi::GenerateApiCode() const -> std::string {
+    std::ostringstream code;
+
+    // Create smartbotic namespace
+    code << R"(
+globalThis.smartbotic = globalThis.smartbotic || {};
+
+// === Logging API ===
+smartbotic.log = {
+    debug: function(msg) { __native_log(0, String(msg)); },
+    info: function(msg) { __native_log(1, String(msg)); },
+    warn: function(msg) { __native_log(2, String(msg)); },
+    error: function(msg) { __native_log(3, String(msg)); }
+};
+
+// Convenience aliases
+var console = {
+    log: smartbotic.log.info,
+    debug: smartbotic.log.debug,
+    info: smartbotic.log.info,
+    warn: smartbotic.log.warn,
+    error: smartbotic.log.error
+};
+)";
+
+    // Storage API (if allowed)
+    if (config_.allow_storage) {
+        code << R"(
+// === Storage API ===
+smartbotic.storage = {
+    get: function(key) { return __native_storage_get(String(key)); },
+    set: function(key, value, ttl) {
+        return __native_storage_set(String(key), String(value), ttl || 0);
+    },
+    del: function(key) { return __native_storage_del(String(key)); },
+    exists: function(key) { return __native_storage_exists(String(key)); },
+    list: function(prefix) { return __native_storage_list(prefix || ''); },
+    count: function(prefix) { return __native_storage_count(prefix || ''); }
+};
+)";
+    } else {
+        code << R"(
+// Storage API (disabled)
+smartbotic.storage = {
+    get: function() { throw new Error('Storage access is disabled for this script'); },
+    set: function() { throw new Error('Storage access is disabled for this script'); },
+    del: function() { throw new Error('Storage access is disabled for this script'); },
+    exists: function() { throw new Error('Storage access is disabled for this script'); },
+    list: function() { throw new Error('Storage access is disabled for this script'); },
+    count: function() { throw new Error('Storage access is disabled for this script'); }
+};
+)";
+    }
+
+    // HTTP API (if allowed)
+    if (config_.allow_network) {
+        code << R"(
+// === HTTP API ===
+smartbotic.http = {
+    get: function(url, opts) {
+        opts = opts || {};
+        return JSON.parse(__native_http_request('GET', url, '', JSON.stringify(opts)));
+    },
+    post: function(url, body, opts) {
+        opts = opts || {};
+        var bodyStr = typeof body === 'object' ? JSON.stringify(body) : String(body || '');
+        return JSON.parse(__native_http_request('POST', url, bodyStr, JSON.stringify(opts)));
+    },
+    put: function(url, body, opts) {
+        opts = opts || {};
+        var bodyStr = typeof body === 'object' ? JSON.stringify(body) : String(body || '');
+        return JSON.parse(__native_http_request('PUT', url, bodyStr, JSON.stringify(opts)));
+    },
+    delete: function(url, opts) {
+        opts = opts || {};
+        return JSON.parse(__native_http_request('DELETE', url, '', JSON.stringify(opts)));
+    }
+};
+)";
+    } else {
+        code << R"(
+// HTTP API (disabled)
+smartbotic.http = {
+    get: function() { throw new Error('Network access is disabled for this script'); },
+    post: function() { throw new Error('Network access is disabled for this script'); },
+    put: function() { throw new Error('Network access is disabled for this script'); },
+    delete: function() { throw new Error('Network access is disabled for this script'); }
+};
+)";
+    }
+
+    // Utility functions (always available)
+    code << R"(
+// === Utils API ===
+smartbotic.utils = {
+    uuid: function() {
+        return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
+            var r = Math.random() * 16 | 0;
+            var v = c === 'x' ? r : (r & 0x3 | 0x8);
+            return v.toString(16);
+        });
+    },
+    sleep: function(ms) {
+        // Synchronous sleep - use sparingly
+        var start = Date.now();
+        while (Date.now() - start < ms) {}
+    },
+    base64Encode: function(str) {
+        // Simple base64 encode for ASCII
+        var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
+        var encoded = '';
+        var i = 0;
+        while (i < str.length) {
+            var a = str.charCodeAt(i++);
+            var b = i < str.length ? str.charCodeAt(i++) : 0;
+            var c = i < str.length ? str.charCodeAt(i++) : 0;
+            var bitmap = (a << 16) | (b << 8) | c;
+            encoded += chars.charAt(bitmap >> 18 & 63) + chars.charAt(bitmap >> 12 & 63);
+            encoded += i - 2 < str.length ? chars.charAt(bitmap >> 6 & 63) : '=';
+            encoded += i - 1 < str.length ? chars.charAt(bitmap & 63) : '=';
+        }
+        return encoded;
+    },
+    base64Decode: function(str) {
+        var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
+        var decoded = '';
+        str = str.replace(/=/g, '');
+        for (var i = 0; i < str.length; i += 4) {
+            var a = chars.indexOf(str.charAt(i));
+            var b = chars.indexOf(str.charAt(i + 1));
+            var c = chars.indexOf(str.charAt(i + 2));
+            var d = chars.indexOf(str.charAt(i + 3));
+            var bitmap = (a << 18) | (b << 12) | (c << 6) | d;
+            decoded += String.fromCharCode((bitmap >> 16) & 255);
+            if (c !== -1) decoded += String.fromCharCode((bitmap >> 8) & 255);
+            if (d !== -1) decoded += String.fromCharCode(bitmap & 255);
+        }
+        return decoded;
+    },
+    timestamp: function() {
+        return Math.floor(Date.now() / 1000);
+    },
+    formatDate: function(ts, fmt) {
+        var date = typeof ts === 'number' ? new Date(ts * 1000) : new Date();
+        fmt = fmt || 'YYYY-MM-DD HH:mm:ss';
+        var pad = function(n) { return n.toString().padStart(2, '0'); };
+        return fmt
+            .replace('YYYY', date.getFullYear())
+            .replace('MM', pad(date.getMonth() + 1))
+            .replace('DD', pad(date.getDate()))
+            .replace('HH', pad(date.getHours()))
+            .replace('mm', pad(date.getMinutes()))
+            .replace('ss', pad(date.getSeconds()));
+    }
+};
+
+// === Document Write API ===
+// Extends the existing db object
+if (typeof db !== 'undefined') {
+    db.create = function(collection, data) {
+        var dataStr = typeof data === 'object' ? JSON.stringify(data) : String(data);
+        var result = __native_doc_create(collection, dataStr);
+        return result ? JSON.parse(result) : null;
+    };
+    db.update = function(collection, id, data) {
+        var dataStr = typeof data === 'object' ? JSON.stringify(data) : String(data);
+        return __native_doc_update(collection, id, dataStr);
+    };
+    db.delete = function(collection, id) {
+        return __native_doc_delete(collection, id);
+    };
+}
+)";
+
+    return code.str();
+}
+
+// === Logging ===
+
+void AutomationApi::Log(LogLevel level, const std::string& message) {
+    LogEntry entry;
+    entry.level = level;
+    entry.message = message;
+    entry.timestamp = GetTimestamp();
+    logs_.push_back(entry);
+
+    if (log_callback_) {
+        log_callback_(level, message);
+    }
+}
+
+// === Storage ===
+
+auto AutomationApi::StorageGet(const std::string& key) -> std::string {
+    if (!config_.allow_storage || !storage_callbacks_.get) {
+        return "";
+    }
+    return storage_callbacks_.get(config_.script_id, key);
+}
+
+auto AutomationApi::StorageSet(const std::string& key, const std::string& value, int ttl) -> bool {
+    if (!config_.allow_storage || !storage_callbacks_.set) {
+        return false;
+    }
+    return storage_callbacks_.set(config_.script_id, key, value, ttl);
+}
+
+auto AutomationApi::StorageDel(const std::string& key) -> bool {
+    if (!config_.allow_storage || !storage_callbacks_.del) {
+        return false;
+    }
+    return storage_callbacks_.del(config_.script_id, key);
+}
+
+auto AutomationApi::StorageExists(const std::string& key) -> bool {
+    if (!config_.allow_storage || !storage_callbacks_.exists) {
+        return false;
+    }
+    return storage_callbacks_.exists(config_.script_id, key);
+}
+
+auto AutomationApi::StorageList(const std::string& prefix) -> std::vector<std::string> {
+    if (!config_.allow_storage || !storage_callbacks_.list) {
+        return {};
+    }
+    return storage_callbacks_.list(config_.script_id, prefix);
+}
+
+auto AutomationApi::StorageCount(const std::string& prefix) -> int64_t {
+    if (!config_.allow_storage || !storage_callbacks_.count) {
+        return 0;
+    }
+    return storage_callbacks_.count(config_.script_id, prefix);
+}
+
+// === HTTP ===
+
+auto AutomationApi::HttpGet(const std::string& url,
+                            const HttpRequestOptions& options) -> HttpResponse {
+    if (!config_.allow_network || !http_callback_) {
+        return {0, "", {}, "Network access is disabled"};
+    }
+    return http_callback_("GET", url, "", options);
+}
+
+auto AutomationApi::HttpPost(const std::string& url, const std::string& body,
+                             const HttpRequestOptions& options) -> HttpResponse {
+    if (!config_.allow_network || !http_callback_) {
+        return {0, "", {}, "Network access is disabled"};
+    }
+    return http_callback_("POST", url, body, options);
+}
+
+auto AutomationApi::HttpPut(const std::string& url, const std::string& body,
+                            const HttpRequestOptions& options) -> HttpResponse {
+    if (!config_.allow_network || !http_callback_) {
+        return {0, "", {}, "Network access is disabled"};
+    }
+    return http_callback_("PUT", url, body, options);
+}
+
+auto AutomationApi::HttpDelete(const std::string& url,
+                               const HttpRequestOptions& options) -> HttpResponse {
+    if (!config_.allow_network || !http_callback_) {
+        return {0, "", {}, "Network access is disabled"};
+    }
+    return http_callback_("DELETE", url, "", options);
+}
+
+// === Document Operations ===
+
+auto AutomationApi::DocCreate(const std::string& collection,
+                               const std::string& data) -> std::string {
+    if (!IsCollectionAllowed(collection)) {
+        return "";
+    }
+    if (!document_callbacks_.create) {
+        return "";
+    }
+    return document_callbacks_.create(collection, data);
+}
+
+auto AutomationApi::DocUpdate(const std::string& collection, const std::string& id,
+                              const std::string& data) -> bool {
+    if (!IsCollectionAllowed(collection)) {
+        return false;
+    }
+    if (!document_callbacks_.update) {
+        return false;
+    }
+    return document_callbacks_.update(collection, id, data);
+}
+
+auto AutomationApi::DocDelete(const std::string& collection, const std::string& id) -> bool {
+    if (!IsCollectionAllowed(collection)) {
+        return false;
+    }
+    if (!document_callbacks_.del) {
+        return false;
+    }
+    return document_callbacks_.del(collection, id);
+}
+
+// === Private ===
+
+auto AutomationApi::IsCollectionAllowed(const std::string& collection) const -> bool {
+    if (config_.allowed_collections.empty()) {
+        return true;  // No restrictions
+    }
+    return std::find(config_.allowed_collections.begin(),
+                     config_.allowed_collections.end(),
+                     collection) != config_.allowed_collections.end();
+}
+
+auto AutomationApi::GetTimestamp() -> std::string {
+    auto now = std::chrono::system_clock::now();
+    auto time = std::chrono::system_clock::to_time_t(now);
+    auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
+        now.time_since_epoch()) % 1000;
+
+    std::tm tm{};
+    gmtime_r(&time, &tm);
+
+    std::ostringstream oss;
+    oss << std::put_time(&tm, "%Y-%m-%dT%H:%M:%S");
+    oss << "." << std::setfill('0') << std::setw(3) << ms.count() << "Z";
+    return oss.str();
+}
+
+}  // namespace smartbotic::runner

+ 2 - 0
webserver/CMakeLists.txt

@@ -33,6 +33,7 @@ add_library(smartbotic_webserver STATIC
     src/tool_service.cpp
     src/archive_service.cpp
     src/event_manager.cpp
+    src/script_service.cpp
 )
 
 target_include_directories(smartbotic_webserver
@@ -47,6 +48,7 @@ target_link_libraries(smartbotic_webserver
     PUBLIC
         smartbotic::common
         smartbotic::proto
+        smartbotic::runner
         spdlog::spdlog
         nlohmann_json::nlohmann_json
         httplib::httplib

+ 11 - 9
webserver/include/smartbotic/webserver/document_service.hpp

@@ -125,15 +125,7 @@ public:
     [[nodiscard]] auto DeleteDocument(const std::string& workspace_id, const std::string& collection,
                                       const std::string& id) -> DocumentResult;
 
-private:
-    /// Get the internal collection name (with workspace prefix)
-    [[nodiscard]] static auto GetInternalCollectionName(const std::string& workspace_id,
-                                                         const std::string& collection) -> std::string;
-
-    /// Convert proto Document to DocumentInfo
-    [[nodiscard]] static auto ProtoToDocumentInfo(const std::string& workspace_id,
-                                                   const std::string& display_collection,
-                                                   const ::smartbotic::database::Document& doc) -> DocumentInfo;
+    // === JSON/Proto Conversion Utilities (public for use by other services) ===
 
     /// Convert nlohmann::json to proto MapValue
     [[nodiscard]] static auto JsonToMapValue(const nlohmann::json& json) -> ::smartbotic::database::MapValue;
@@ -147,6 +139,16 @@ private:
     /// Convert nlohmann::json to proto Value
     [[nodiscard]] static auto JsonToValue(const nlohmann::json& json) -> ::smartbotic::database::Value;
 
+private:
+    /// Get the internal collection name (with workspace prefix)
+    [[nodiscard]] static auto GetInternalCollectionName(const std::string& workspace_id,
+                                                         const std::string& collection) -> std::string;
+
+    /// Convert proto Document to DocumentInfo
+    [[nodiscard]] static auto ProtoToDocumentInfo(const std::string& workspace_id,
+                                                   const std::string& display_collection,
+                                                   const ::smartbotic::database::Document& doc) -> DocumentInfo;
+
     DatabaseClient& db_client_;
     EventManager* event_manager_ = nullptr;
 };

+ 11 - 1
webserver/include/smartbotic/webserver/event_manager.hpp

@@ -19,7 +19,9 @@ enum class EntityType {
     Session,
     User,
     Group,
-    Membership
+    Membership,
+    Script,
+    ScriptExecution
 };
 
 /// Action types for events
@@ -111,6 +113,14 @@ public:
                              const std::string& workspace_id, const nlohmann::json& data,
                              const std::string& actor_id = "");
 
+    void EmitScriptEvent(EventAction action, const std::string& script_id,
+                         const std::string& workspace_id, const nlohmann::json& data,
+                         const std::string& actor_id = "");
+
+    void EmitScriptExecutionEvent(EventAction action, const std::string& execution_id,
+                                  const std::string& workspace_id, const nlohmann::json& data,
+                                  const std::string& actor_id = "");
+
 private:
     std::mutex mutex_;
     std::vector<std::pair<size_t, EventListener>> listeners_;

+ 16 - 0
webserver/include/smartbotic/webserver/http_server.hpp

@@ -31,6 +31,7 @@
 #include "smartbotic/webserver/tool_service.hpp"
 #include "smartbotic/webserver/archive_service.hpp"
 #include "smartbotic/webserver/event_manager.hpp"
+#include "smartbotic/webserver/script_service.hpp"
 
 namespace smartbotic::webserver {
 
@@ -209,6 +210,9 @@ public:
     /// Get the event manager
     [[nodiscard]] auto GetEventManager() -> EventManager& { return *eventManager_; }
 
+    /// Get the script service
+    [[nodiscard]] auto GetScriptService() -> ScriptService& { return *scriptService_; }
+
     /// Broadcast a document event to subscribed WebSocket clients
     void BroadcastDocumentEvent(const std::string& workspace_id,
                                  const std::string& collection,
@@ -246,6 +250,7 @@ private:
     void SetupPageRoutes();
     void SetupLlmRoutes();
     void SetupArchiveRoutes();
+    void SetupScriptRoutes();
     void RunHttpServer();
     [[nodiscard]] auto ConnectToDatabase() -> bool;
     [[nodiscard]] auto InitializeServices() -> bool;
@@ -372,6 +377,16 @@ private:
     void HandleDeleteArchive(const httplib::Request& req, httplib::Response& res);
     void HandleCleanupArchives(const httplib::Request& req, httplib::Response& res);
 
+    // Script route handlers
+    void HandleCreateScript(const httplib::Request& req, httplib::Response& res);
+    void HandleListScripts(const httplib::Request& req, httplib::Response& res);
+    void HandleGetScript(const httplib::Request& req, httplib::Response& res);
+    void HandleUpdateScript(const httplib::Request& req, httplib::Response& res);
+    void HandleDeleteScript(const httplib::Request& req, httplib::Response& res);
+    void HandleExecuteScript(const httplib::Request& req, httplib::Response& res);
+    void HandleListScriptExecutions(const httplib::Request& req, httplib::Response& res);
+    void HandleGetScriptExecution(const httplib::Request& req, httplib::Response& res);
+
     // Authentication middleware helper
     [[nodiscard]] auto AuthenticateRequest(const httplib::Request& req) -> std::optional<AuthUser>;
 
@@ -406,6 +421,7 @@ private:
     std::unique_ptr<ToolService> toolService_;
     std::unique_ptr<ArchiveService> archiveService_;
     std::unique_ptr<EventManager> eventManager_;
+    std::unique_ptr<ScriptService> scriptService_;
 
     std::thread httpThread_;
 

+ 32 - 0
webserver/include/smartbotic/webserver/permissions.hpp

@@ -165,6 +165,30 @@ constexpr std::string_view kCollectionManage = "collection:*:*:manage";       //
 constexpr std::string_view kFieldRead = "field:*:*:*:read";   // Read field
 constexpr std::string_view kFieldWrite = "field:*:*:*:write"; // Write field
 
+// ============================================================================
+// SCRIPT SCOPE - Per-workspace script operations (ownership-based)
+// Format: script:{workspace_id}:{action}
+// Similar to pages with read_all/read_own, write_all/write_own pattern
+// ============================================================================
+
+// Create permission
+constexpr std::string_view kScriptCreate = "script:*:create";        // Create new scripts
+
+// Read permissions
+constexpr std::string_view kScriptReadAll = "script:*:read_all";     // Read any script
+constexpr std::string_view kScriptReadOwn = "script:*:read_own";     // Read own scripts only
+
+// Write permissions
+constexpr std::string_view kScriptWriteAll = "script:*:write_all";   // Write any script
+constexpr std::string_view kScriptWriteOwn = "script:*:write_own";   // Write own scripts only
+
+// Delete permissions
+constexpr std::string_view kScriptDeleteAll = "script:*:delete_all"; // Delete any script
+constexpr std::string_view kScriptDeleteOwn = "script:*:delete_own"; // Delete own scripts only
+
+// Execute permission
+constexpr std::string_view kScriptExecute = "script:*:execute";      // Execute scripts
+
 // ============================================================================
 // SPECIAL
 // ============================================================================
@@ -206,6 +230,11 @@ constexpr std::string_view kAuthenticatedGroupName = "authenticated";
                                          std::string_view field,
                                          std::string_view action) -> std::string;
 
+/// Build a specific script permission
+/// e.g., BuildScriptPermission("ws1", "read_all") -> "script:ws1:read_all"
+[[nodiscard]] auto BuildScriptPermission(std::string_view workspace_id,
+                                          std::string_view action) -> std::string;
+
 /// Check if a permission matches a pattern (with wildcard support)
 /// e.g., MatchesPermission("collection:ws1:customers:read_all", "collection:*:*:read_all") -> true
 /// e.g., MatchesPermission("system:users:read", "*") -> true
@@ -228,4 +257,7 @@ constexpr std::string_view kAuthenticatedGroupName = "authenticated";
 /// Get all defined page actions (for UI enumeration)
 [[nodiscard]] auto GetAllPageActions() -> std::vector<std::string_view>;
 
+/// Get all defined script actions (for UI enumeration)
+[[nodiscard]] auto GetAllScriptActions() -> std::vector<std::string_view>;
+
 }  // namespace smartbotic::permissions

+ 314 - 0
webserver/include/smartbotic/webserver/script_service.hpp

@@ -0,0 +1,314 @@
+#pragma once
+
+#include <atomic>
+#include <chrono>
+#include <functional>
+#include <map>
+#include <mutex>
+#include <optional>
+#include <string>
+#include <thread>
+#include <vector>
+
+#include <nlohmann/json.hpp>
+
+#include "smartbotic/webserver/database_client.hpp"
+
+namespace smartbotic::webserver {
+
+// Forward declarations
+class EventManager;
+class AuthorizationService;
+struct EntityEvent;
+struct AuthUser;
+
+/// Script trigger types
+enum class TriggerType {
+    Event,  // Triggered by entity events (document create, update, delete)
+    Cron    // Triggered by cron schedule
+};
+
+/// Script status
+enum class ScriptStatus {
+    Active,    // Script is active and can be triggered
+    Inactive,  // Script is disabled
+    Draft      // Script is being edited
+};
+
+/// Script execution status
+enum class ExecutionStatus {
+    Running,   // Currently executing
+    Success,   // Completed successfully
+    Error,     // Completed with error
+    Timeout    // Execution timed out
+};
+
+/// A trigger configuration for a script
+struct ScriptTrigger {
+    std::string id;
+    TriggerType type = TriggerType::Event;
+
+    // For event triggers
+    std::string entity_type;       // Document, Collection, etc.
+    std::string event_action;      // Create, Update, Delete
+    std::string collection_filter; // Optional: specific collection to match
+
+    // For cron triggers
+    std::string cron_expression;   // e.g., "0 * * * *" (hourly)
+    std::string timezone;          // e.g., "UTC", "Europe/Budapest"
+
+    // Tracking
+    std::string last_triggered;
+    int64_t trigger_count = 0;
+};
+
+/// Script configuration
+struct ScriptConfig {
+    int timeout_ms = 5000;         // Max execution time (default 5s, max 30s)
+    int max_memory_mb = 16;        // Max memory (default 16MB, max 64MB)
+    bool allow_network = false;    // Allow HTTP requests
+    bool allow_storage = true;     // Allow key-value storage
+    std::vector<std::string> allowed_collections;  // Collections script can access
+};
+
+/// Script information
+struct ScriptInfo {
+    std::string id;
+    std::string workspace_id;
+    std::string name;
+    std::string description;
+    std::string code;
+    ScriptStatus status = ScriptStatus::Draft;
+    ScriptConfig config;
+    std::vector<ScriptTrigger> triggers;
+    std::string created_at;
+    std::string updated_at;
+    std::string created_by;        // Owner - scripts inherit creator's permissions
+    std::string deleted_at;        // Soft delete timestamp
+
+    [[nodiscard]] auto IsDeleted() const -> bool { return !deleted_at.empty(); }
+};
+
+/// Script execution record
+struct ScriptExecution {
+    std::string id;
+    std::string script_id;
+    std::string workspace_id;
+    std::string trigger_type;      // "event", "cron", "manual"
+    nlohmann::json trigger_event;  // Event data that triggered execution
+    ExecutionStatus status = ExecutionStatus::Running;
+    std::string result;            // Return value
+    std::string error;             // Error message if failed
+    std::vector<std::string> logs; // Console logs
+    std::string started_at;
+    std::string completed_at;
+    int64_t execution_time_ms = 0;
+    std::string executed_by;       // User who triggered (manual) or script creator
+};
+
+/// Request to create a script
+struct CreateScriptRequest {
+    std::string workspace_id;
+    std::string name;
+    std::string description;
+    std::string code;
+    ScriptConfig config;
+    std::vector<ScriptTrigger> triggers;
+    std::string created_by;        // Actor ID
+};
+
+/// Request to update a script
+struct UpdateScriptRequest {
+    std::string workspace_id;
+    std::string id;
+    std::optional<std::string> name;
+    std::optional<std::string> description;
+    std::optional<std::string> code;
+    std::optional<ScriptStatus> status;
+    std::optional<ScriptConfig> config;
+    std::optional<std::vector<ScriptTrigger>> triggers;
+};
+
+/// Request to execute a script
+struct ExecuteScriptRequest {
+    std::string workspace_id;
+    std::string script_id;
+    std::string executed_by;       // User executing the script
+    std::string trigger_type;      // "manual", "event", "cron"
+    nlohmann::json trigger_event;  // Event context for event triggers
+};
+
+/// Result of a script operation
+struct ScriptResult {
+    bool success = false;
+    std::string error;
+    std::optional<ScriptInfo> script;
+};
+
+/// Result of listing scripts
+struct ScriptListResult {
+    bool success = false;
+    std::string error;
+    std::vector<ScriptInfo> scripts;
+    int64_t total_count = 0;
+};
+
+/// Result of a script execution
+struct ScriptExecutionResult {
+    bool success = false;
+    std::string error;
+    std::optional<ScriptExecution> execution;
+};
+
+/// Result of listing executions
+struct ExecutionListResult {
+    bool success = false;
+    std::string error;
+    std::vector<ScriptExecution> executions;
+    int64_t total_count = 0;
+};
+
+/// Cron schedule entry for scheduler
+struct CronScheduleEntry {
+    std::string script_id;
+    std::string workspace_id;
+    std::string cron_expression;
+    std::string timezone;
+    std::string trigger_id;
+    std::chrono::system_clock::time_point next_run;
+};
+
+/// Service for managing automation scripts
+class ScriptService {
+public:
+    explicit ScriptService(DatabaseClient& db_client);
+    ~ScriptService();
+
+    /// Set the event manager for notifications and subscriptions
+    void SetEventManager(EventManager* event_manager);
+
+    /// Set the authorization service for permission checks
+    void SetAuthorizationService(AuthorizationService* auth_service);
+
+    // Disable copy and move (atomic and mutex are not movable)
+    ScriptService(const ScriptService&) = delete;
+    auto operator=(const ScriptService&) -> ScriptService& = delete;
+    ScriptService(ScriptService&&) = delete;
+    auto operator=(ScriptService&&) -> ScriptService& = delete;
+
+    /// Initialize the service (create system collections if needed)
+    [[nodiscard]] auto Initialize() -> bool;
+
+    /// Start the cron scheduler background thread
+    void StartScheduler();
+
+    /// Stop the cron scheduler
+    void StopScheduler();
+
+    // === CRUD Operations ===
+
+    /// Create a new script
+    [[nodiscard]] auto CreateScript(const CreateScriptRequest& request) -> ScriptResult;
+
+    /// Get a script by ID
+    [[nodiscard]] auto GetScript(const std::string& workspace_id, const std::string& id,
+                                 bool include_deleted = false) -> ScriptResult;
+
+    /// List all scripts in a workspace
+    [[nodiscard]] auto ListScripts(const std::string& workspace_id,
+                                   bool include_deleted = false) -> ScriptListResult;
+
+    /// Update a script
+    [[nodiscard]] auto UpdateScript(const UpdateScriptRequest& request) -> ScriptResult;
+
+    /// Delete a script (soft delete)
+    [[nodiscard]] auto DeleteScript(const std::string& workspace_id,
+                                    const std::string& id) -> ScriptResult;
+
+    // === Execution Operations ===
+
+    /// Execute a script manually or from trigger
+    [[nodiscard]] auto ExecuteScript(const ExecuteScriptRequest& request) -> ScriptExecutionResult;
+
+    /// Get an execution by ID
+    [[nodiscard]] auto GetExecution(const std::string& workspace_id,
+                                    const std::string& execution_id) -> ScriptExecutionResult;
+
+    /// List executions for a script
+    [[nodiscard]] auto ListExecutions(const std::string& workspace_id,
+                                      const std::string& script_id,
+                                      int limit = 50,
+                                      int offset = 0) -> ExecutionListResult;
+
+    // === Event Handling ===
+
+    /// Handle an entity event (for event-triggered scripts)
+    void OnEntityEvent(const EntityEvent& event);
+
+    // === JSON Conversion (public for HTTP handlers) ===
+    [[nodiscard]] static auto ScriptToJson(const ScriptInfo& script) -> nlohmann::json;
+    [[nodiscard]] static auto JsonToScript(const nlohmann::json& json) -> ScriptInfo;
+    [[nodiscard]] static auto ExecutionToJson(const ScriptExecution& execution) -> nlohmann::json;
+    [[nodiscard]] static auto JsonToExecution(const nlohmann::json& json) -> ScriptExecution;
+    [[nodiscard]] static auto TriggerToJson(const ScriptTrigger& trigger) -> nlohmann::json;
+    [[nodiscard]] static auto JsonToTrigger(const nlohmann::json& json) -> ScriptTrigger;
+    [[nodiscard]] static auto ConfigToJson(const ScriptConfig& config) -> nlohmann::json;
+    [[nodiscard]] static auto JsonToConfig(const nlohmann::json& json) -> ScriptConfig;
+
+    [[nodiscard]] static auto StatusToString(ScriptStatus status) -> std::string;
+    [[nodiscard]] static auto StringToStatus(const std::string& str) -> ScriptStatus;
+    [[nodiscard]] static auto ExecStatusToString(ExecutionStatus status) -> std::string;
+    [[nodiscard]] static auto StringToExecStatus(const std::string& str) -> ExecutionStatus;
+    [[nodiscard]] static auto TriggerTypeToString(TriggerType type) -> std::string;
+    [[nodiscard]] static auto StringToTriggerType(const std::string& str) -> TriggerType;
+
+private:
+    // Collection names
+    static constexpr const char* kScriptsCollection = "_scripts";
+    static constexpr const char* kExecutionsCollection = "_script_executions";
+    static constexpr const char* kStorageCollection = "_script_storage";
+
+    /// Subscribe to entity events for triggers
+    void SubscribeToEvents();
+
+    /// Find scripts triggered by an event
+    [[nodiscard]] auto FindTriggeredScripts(const EntityEvent& event) -> std::vector<ScriptInfo>;
+
+    /// Run the cron scheduler loop
+    void SchedulerLoop();
+
+    /// Update cron schedule for a script
+    void UpdateCronSchedule(const ScriptInfo& script);
+
+    /// Remove cron schedules for a script
+    void RemoveCronSchedule(const std::string& script_id);
+
+    /// Check and execute due cron scripts
+    void CheckCronTriggers();
+
+    /// Parse cron expression and calculate next run time
+    [[nodiscard]] auto CalculateNextRun(const std::string& cron_expression,
+                                        const std::string& timezone) -> std::chrono::system_clock::time_point;
+
+    /// Check if creator has permission to access a collection
+    [[nodiscard]] auto CreatorCanAccessCollection(const std::string& creator_id,
+                                                   const std::string& workspace_id,
+                                                   const std::string& collection,
+                                                   const std::string& action) -> bool;
+
+    [[nodiscard]] static auto GetCurrentTimestamp() -> std::string;
+    [[nodiscard]] static auto GenerateUuid() -> std::string;
+
+    DatabaseClient& db_client_;
+    EventManager* event_manager_ = nullptr;
+    AuthorizationService* auth_service_ = nullptr;
+    size_t event_listener_id_ = 0;
+
+    // Cron scheduler
+    std::atomic<bool> scheduler_running_{false};
+    std::thread scheduler_thread_;
+    std::mutex schedule_mutex_;
+    std::vector<CronScheduleEntry> cron_schedules_;
+};
+
+}  // namespace smartbotic::webserver

+ 2 - 0
webserver/src/archive_service.cpp

@@ -472,6 +472,8 @@ auto ArchiveService::RestoreArchive(const std::string& archive_id,
         case EntityType::User:
         case EntityType::Group:
         case EntityType::Membership:
+        case EntityType::Script:
+        case EntityType::ScriptExecution:
             // These types are not typically archived/restored via this service
             result.restored_entity_id = archive.entity_id;
             break;

+ 40 - 10
webserver/src/event_manager.cpp

@@ -6,16 +6,18 @@ namespace smartbotic::webserver {
 
 auto EntityTypeToString(EntityType type) -> std::string {
     switch (type) {
-        case EntityType::Workspace:  return "workspace";
-        case EntityType::Page:       return "page";
-        case EntityType::View:       return "view";
-        case EntityType::Collection: return "collection";
-        case EntityType::Document:   return "document";
-        case EntityType::Session:    return "session";
-        case EntityType::User:       return "user";
-        case EntityType::Group:      return "group";
-        case EntityType::Membership: return "membership";
-        default:                     return "unknown";
+        case EntityType::Workspace:       return "workspace";
+        case EntityType::Page:            return "page";
+        case EntityType::View:            return "view";
+        case EntityType::Collection:      return "collection";
+        case EntityType::Document:        return "document";
+        case EntityType::Session:         return "session";
+        case EntityType::User:            return "user";
+        case EntityType::Group:           return "group";
+        case EntityType::Membership:      return "membership";
+        case EntityType::Script:          return "script";
+        case EntityType::ScriptExecution: return "script_execution";
+        default:                          return "unknown";
     }
 }
 
@@ -193,4 +195,32 @@ void EventManager::EmitMembershipEvent(EventAction action, const std::string& me
     });
 }
 
+void EventManager::EmitScriptEvent(EventAction action, const std::string& script_id,
+                                    const std::string& workspace_id, const nlohmann::json& data,
+                                    const std::string& actor_id) {
+    Emit({
+        .entity_type = EntityType::Script,
+        .action = action,
+        .entity_id = script_id,
+        .workspace_id = workspace_id,
+        .collection_name = "",
+        .data = data,
+        .actor_id = actor_id
+    });
+}
+
+void EventManager::EmitScriptExecutionEvent(EventAction action, const std::string& execution_id,
+                                             const std::string& workspace_id, const nlohmann::json& data,
+                                             const std::string& actor_id) {
+    Emit({
+        .entity_type = EntityType::ScriptExecution,
+        .action = action,
+        .entity_id = execution_id,
+        .workspace_id = workspace_id,
+        .collection_name = "",
+        .data = data,
+        .actor_id = actor_id
+    });
+}
+
 }  // namespace smartbotic::webserver

+ 669 - 0
webserver/src/http_server.cpp

@@ -748,6 +748,17 @@ auto HttpServer::InitializeServices() -> bool {
     toolService_->Initialize();
     spdlog::info("ToolService initialized successfully");
 
+    // Initialize ScriptService for automation
+    scriptService_ = std::make_unique<ScriptService>(*dbClient_);
+    if (!scriptService_->Initialize()) {
+        spdlog::error("Failed to initialize ScriptService");
+        return false;
+    }
+    scriptService_->SetEventManager(eventManager_.get());
+    scriptService_->SetAuthorizationService(authorizationService_.get());
+    scriptService_->StartScheduler();
+    spdlog::info("ScriptService initialized successfully");
+
     // Note: WebSocket message handler is set up in Start() after wsServer_ is created
 
     return true;
@@ -842,6 +853,9 @@ void HttpServer::SetupRoutes() {
     // Setup archive routes (for viewing/restoring deleted items)
     SetupArchiveRoutes();
 
+    // Setup script routes (automation system)
+    SetupScriptRoutes();
+
     // Setup collection routes (before workspace routes since they're more specific)
     SetupCollectionRoutes();
 
@@ -8539,4 +8553,659 @@ void HttpServer::HandleCleanupArchives(const httplib::Request& req, httplib::Res
     }
 }
 
+// ============================================================================
+// Script Routes (Automation System)
+// ============================================================================
+
+void HttpServer::SetupScriptRoutes() {
+    // POST /api/workspaces/:wid/scripts - Create script
+    httpServer_->Post(R"(/api/workspaces/([^/]+)/scripts$)",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleCreateScript(req, res);
+        });
+
+    // GET /api/workspaces/:wid/scripts - List scripts
+    httpServer_->Get(R"(/api/workspaces/([^/]+)/scripts$)",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleListScripts(req, res);
+        });
+
+    // GET /api/workspaces/:wid/scripts/:id - Get script
+    httpServer_->Get(R"(/api/workspaces/([^/]+)/scripts/([^/]+)$)",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleGetScript(req, res);
+        });
+
+    // PATCH /api/workspaces/:wid/scripts/:id - Update script
+    httpServer_->Patch(R"(/api/workspaces/([^/]+)/scripts/([^/]+)$)",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleUpdateScript(req, res);
+        });
+
+    // DELETE /api/workspaces/:wid/scripts/:id - Delete script
+    httpServer_->Delete(R"(/api/workspaces/([^/]+)/scripts/([^/]+)$)",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleDeleteScript(req, res);
+        });
+
+    // POST /api/workspaces/:wid/scripts/:id/execute - Execute script
+    httpServer_->Post(R"(/api/workspaces/([^/]+)/scripts/([^/]+)/execute$)",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleExecuteScript(req, res);
+        });
+
+    // GET /api/workspaces/:wid/scripts/:id/executions - List executions
+    httpServer_->Get(R"(/api/workspaces/([^/]+)/scripts/([^/]+)/executions$)",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleListScriptExecutions(req, res);
+        });
+
+    // GET /api/workspaces/:wid/executions/:eid - Get execution
+    httpServer_->Get(R"(/api/workspaces/([^/]+)/executions/([^/]+)$)",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleGetScriptExecution(req, res);
+        });
+
+    spdlog::info("Script routes registered");
+}
+
+void HttpServer::HandleCreateScript(const httplib::Request& req, httplib::Response& res) {
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    auto workspace_id = req.matches[1].str();
+
+    // Check create permission
+    auto create_perm = permissions::BuildScriptPermission(workspace_id, "create");
+    if (!authorizationService_->HasPermission(*auth_user, create_perm)) {
+        res.status = 403;
+        res.set_content(R"({"error":"Forbidden - insufficient permissions"})", "application/json");
+        return;
+    }
+
+    try {
+        auto body = nlohmann::json::parse(req.body);
+
+        CreateScriptRequest create_req;
+        create_req.workspace_id = workspace_id;
+        create_req.name = body.value("name", "");
+        create_req.description = body.value("description", "");
+        create_req.code = body.value("code", "");
+        create_req.created_by = auth_user->user_id;
+
+        // Parse config
+        if (body.contains("config") && body["config"].is_object()) {
+            const auto& config = body["config"];
+            create_req.config.timeout_ms = config.value("timeout_ms", 5000);
+            create_req.config.max_memory_mb = config.value("max_memory_mb", 16);
+            create_req.config.allow_network = config.value("allow_network", false);
+            create_req.config.allow_storage = config.value("allow_storage", true);
+            if (config.contains("allowed_collections") && config["allowed_collections"].is_array()) {
+                for (const auto& c : config["allowed_collections"]) {
+                    create_req.config.allowed_collections.push_back(c.get<std::string>());
+                }
+            }
+        }
+
+        // Parse triggers
+        if (body.contains("triggers") && body["triggers"].is_array()) {
+            for (const auto& t : body["triggers"]) {
+                ScriptTrigger trigger;
+                trigger.id = t.value("id", "");
+                std::string type_str = t.value("type", "event");
+                trigger.type = type_str == "cron" ? TriggerType::Cron : TriggerType::Event;
+                trigger.entity_type = t.value("entity_type", "");
+                trigger.event_action = t.value("event_action", "");
+                trigger.collection_filter = t.value("collection_filter", "");
+                trigger.cron_expression = t.value("cron_expression", "");
+                trigger.timezone = t.value("timezone", "UTC");
+                create_req.triggers.push_back(trigger);
+            }
+        }
+
+        auto result = scriptService_->CreateScript(create_req);
+        if (!result.success) {
+            res.status = 400;
+            res.set_content(nlohmann::json{{"error", result.error}}.dump(), "application/json");
+            return;
+        }
+
+        const auto& script = *result.script;
+        nlohmann::json response;
+        response["id"] = script.id;
+        response["workspace_id"] = script.workspace_id;
+        response["name"] = script.name;
+        response["description"] = script.description;
+        response["code"] = script.code;
+        response["status"] = ScriptService::StatusToString(script.status);
+        response["config"] = ScriptService::ConfigToJson(script.config);
+        response["triggers"] = nlohmann::json::array();
+        for (const auto& t : script.triggers) {
+            response["triggers"].push_back(ScriptService::TriggerToJson(t));
+        }
+        response["created_at"] = script.created_at;
+        response["updated_at"] = script.updated_at;
+        response["created_by"] = script.created_by;
+
+        res.status = 201;
+        res.set_content(response.dump(), "application/json");
+
+    } catch (const nlohmann::json::exception& e) {
+        res.status = 400;
+        res.set_content(nlohmann::json{{"error", "Invalid JSON: " + std::string(e.what())}}.dump(), "application/json");
+    } catch (const std::exception& e) {
+        spdlog::error("CreateScript error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleListScripts(const httplib::Request& req, httplib::Response& res) {
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    auto workspace_id = req.matches[1].str();
+
+    // Check read permission (read_all or read_own)
+    auto read_all = permissions::BuildScriptPermission(workspace_id, "read_all");
+    auto read_own = permissions::BuildScriptPermission(workspace_id, "read_own");
+    bool can_read_all = authorizationService_->HasPermission(*auth_user, read_all);
+    bool can_read_own = authorizationService_->HasPermission(*auth_user, read_own);
+
+    if (!can_read_all && !can_read_own) {
+        res.status = 403;
+        res.set_content(R"({"error":"Forbidden - insufficient permissions"})", "application/json");
+        return;
+    }
+
+    try {
+        auto result = scriptService_->ListScripts(workspace_id, false);
+        if (!result.success) {
+            res.status = 500;
+            res.set_content(nlohmann::json{{"error", result.error}}.dump(), "application/json");
+            return;
+        }
+
+        nlohmann::json response;
+        response["scripts"] = nlohmann::json::array();
+
+        for (const auto& script : result.scripts) {
+            // If can_read_all or user owns the script
+            if (can_read_all || script.created_by == auth_user->user_id) {
+                nlohmann::json script_json;
+                script_json["id"] = script.id;
+                script_json["workspace_id"] = script.workspace_id;
+                script_json["name"] = script.name;
+                script_json["description"] = script.description;
+                script_json["status"] = ScriptService::StatusToString(script.status);
+                script_json["trigger_count"] = script.triggers.size();
+                script_json["created_at"] = script.created_at;
+                script_json["updated_at"] = script.updated_at;
+                script_json["created_by"] = script.created_by;
+                response["scripts"].push_back(script_json);
+            }
+        }
+        response["total_count"] = response["scripts"].size();
+
+        res.set_content(response.dump(), "application/json");
+
+    } catch (const std::exception& e) {
+        spdlog::error("ListScripts error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleGetScript(const httplib::Request& req, httplib::Response& res) {
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    auto workspace_id = req.matches[1].str();
+    auto script_id = req.matches[2].str();
+
+    try {
+        auto result = scriptService_->GetScript(workspace_id, script_id, false);
+        if (!result.success || !result.script) {
+            res.status = 404;
+            res.set_content(nlohmann::json{{"error", result.error.empty() ? "Script not found" : result.error}}.dump(), "application/json");
+            return;
+        }
+
+        const auto& script = *result.script;
+
+        // Check permission
+        auto read_all = permissions::BuildScriptPermission(workspace_id, "read_all");
+        auto read_own = permissions::BuildScriptPermission(workspace_id, "read_own");
+        bool can_read = authorizationService_->HasPermission(*auth_user, read_all) ||
+                        (authorizationService_->HasPermission(*auth_user, read_own) && script.created_by == auth_user->user_id);
+
+        if (!can_read) {
+            res.status = 403;
+            res.set_content(R"({"error":"Forbidden - insufficient permissions"})", "application/json");
+            return;
+        }
+
+        nlohmann::json response;
+        response["id"] = script.id;
+        response["workspace_id"] = script.workspace_id;
+        response["name"] = script.name;
+        response["description"] = script.description;
+        response["code"] = script.code;
+        response["status"] = ScriptService::StatusToString(script.status);
+        response["config"] = ScriptService::ConfigToJson(script.config);
+        response["triggers"] = nlohmann::json::array();
+        for (const auto& t : script.triggers) {
+            response["triggers"].push_back(ScriptService::TriggerToJson(t));
+        }
+        response["created_at"] = script.created_at;
+        response["updated_at"] = script.updated_at;
+        response["created_by"] = script.created_by;
+
+        res.set_content(response.dump(), "application/json");
+
+    } catch (const std::exception& e) {
+        spdlog::error("GetScript error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleUpdateScript(const httplib::Request& req, httplib::Response& res) {
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    auto workspace_id = req.matches[1].str();
+    auto script_id = req.matches[2].str();
+
+    try {
+        // First get the script to check ownership
+        auto get_result = scriptService_->GetScript(workspace_id, script_id, false);
+        if (!get_result.success || !get_result.script) {
+            res.status = 404;
+            res.set_content(R"({"error":"Script not found"})", "application/json");
+            return;
+        }
+
+        const auto& existing_script = *get_result.script;
+
+        // Check write permission
+        auto write_all = permissions::BuildScriptPermission(workspace_id, "write_all");
+        auto write_own = permissions::BuildScriptPermission(workspace_id, "write_own");
+        bool can_write = authorizationService_->HasPermission(*auth_user, write_all) ||
+                         (authorizationService_->HasPermission(*auth_user, write_own) && existing_script.created_by == auth_user->user_id);
+
+        if (!can_write) {
+            res.status = 403;
+            res.set_content(R"({"error":"Forbidden - insufficient permissions"})", "application/json");
+            return;
+        }
+
+        auto body = nlohmann::json::parse(req.body);
+
+        UpdateScriptRequest update_req;
+        update_req.workspace_id = workspace_id;
+        update_req.id = script_id;
+
+        if (body.contains("name")) {
+            update_req.name = body["name"].get<std::string>();
+        }
+        if (body.contains("description")) {
+            update_req.description = body["description"].get<std::string>();
+        }
+        if (body.contains("code")) {
+            update_req.code = body["code"].get<std::string>();
+        }
+        if (body.contains("status")) {
+            std::string status_str = body["status"].get<std::string>();
+            update_req.status = ScriptService::StringToStatus(status_str);
+        }
+
+        // Parse config
+        if (body.contains("config") && body["config"].is_object()) {
+            ScriptConfig config;
+            const auto& config_json = body["config"];
+            config.timeout_ms = config_json.value("timeout_ms", 5000);
+            config.max_memory_mb = config_json.value("max_memory_mb", 16);
+            config.allow_network = config_json.value("allow_network", false);
+            config.allow_storage = config_json.value("allow_storage", true);
+            if (config_json.contains("allowed_collections") && config_json["allowed_collections"].is_array()) {
+                for (const auto& c : config_json["allowed_collections"]) {
+                    config.allowed_collections.push_back(c.get<std::string>());
+                }
+            }
+            update_req.config = config;
+        }
+
+        // Parse triggers
+        if (body.contains("triggers") && body["triggers"].is_array()) {
+            std::vector<ScriptTrigger> triggers;
+            for (const auto& t : body["triggers"]) {
+                ScriptTrigger trigger;
+                trigger.id = t.value("id", "");
+                std::string type_str = t.value("type", "event");
+                trigger.type = type_str == "cron" ? TriggerType::Cron : TriggerType::Event;
+                trigger.entity_type = t.value("entity_type", "");
+                trigger.event_action = t.value("event_action", "");
+                trigger.collection_filter = t.value("collection_filter", "");
+                trigger.cron_expression = t.value("cron_expression", "");
+                trigger.timezone = t.value("timezone", "UTC");
+                triggers.push_back(trigger);
+            }
+            update_req.triggers = triggers;
+        }
+
+        auto result = scriptService_->UpdateScript(update_req);
+        if (!result.success) {
+            res.status = 400;
+            res.set_content(nlohmann::json{{"error", result.error}}.dump(), "application/json");
+            return;
+        }
+
+        const auto& script = *result.script;
+        nlohmann::json response;
+        response["id"] = script.id;
+        response["workspace_id"] = script.workspace_id;
+        response["name"] = script.name;
+        response["description"] = script.description;
+        response["code"] = script.code;
+        response["status"] = ScriptService::StatusToString(script.status);
+        response["config"] = ScriptService::ConfigToJson(script.config);
+        response["triggers"] = nlohmann::json::array();
+        for (const auto& t : script.triggers) {
+            response["triggers"].push_back(ScriptService::TriggerToJson(t));
+        }
+        response["created_at"] = script.created_at;
+        response["updated_at"] = script.updated_at;
+        response["created_by"] = script.created_by;
+
+        res.set_content(response.dump(), "application/json");
+
+    } catch (const nlohmann::json::exception& e) {
+        res.status = 400;
+        res.set_content(nlohmann::json{{"error", "Invalid JSON: " + std::string(e.what())}}.dump(), "application/json");
+    } catch (const std::exception& e) {
+        spdlog::error("UpdateScript error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleDeleteScript(const httplib::Request& req, httplib::Response& res) {
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    auto workspace_id = req.matches[1].str();
+    auto script_id = req.matches[2].str();
+
+    try {
+        // First get the script to check ownership
+        auto get_result = scriptService_->GetScript(workspace_id, script_id, false);
+        if (!get_result.success || !get_result.script) {
+            res.status = 404;
+            res.set_content(R"({"error":"Script not found"})", "application/json");
+            return;
+        }
+
+        const auto& existing_script = *get_result.script;
+
+        // Check delete permission
+        auto delete_all = permissions::BuildScriptPermission(workspace_id, "delete_all");
+        auto delete_own = permissions::BuildScriptPermission(workspace_id, "delete_own");
+        bool can_delete = authorizationService_->HasPermission(*auth_user, delete_all) ||
+                          (authorizationService_->HasPermission(*auth_user, delete_own) && existing_script.created_by == auth_user->user_id);
+
+        if (!can_delete) {
+            res.status = 403;
+            res.set_content(R"({"error":"Forbidden - insufficient permissions"})", "application/json");
+            return;
+        }
+
+        auto result = scriptService_->DeleteScript(workspace_id, script_id);
+        if (!result.success) {
+            res.status = 400;
+            res.set_content(nlohmann::json{{"error", result.error}}.dump(), "application/json");
+            return;
+        }
+
+        nlohmann::json response;
+        response["success"] = true;
+        response["message"] = "Script deleted";
+        res.set_content(response.dump(), "application/json");
+
+    } catch (const std::exception& e) {
+        spdlog::error("DeleteScript error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleExecuteScript(const httplib::Request& req, httplib::Response& res) {
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    auto workspace_id = req.matches[1].str();
+    auto script_id = req.matches[2].str();
+
+    // Check execute permission
+    auto execute_perm = permissions::BuildScriptPermission(workspace_id, "execute");
+    if (!authorizationService_->HasPermission(*auth_user, execute_perm)) {
+        res.status = 403;
+        res.set_content(R"({"error":"Forbidden - insufficient permissions"})", "application/json");
+        return;
+    }
+
+    try {
+        nlohmann::json body = nlohmann::json::object();
+        if (!req.body.empty()) {
+            body = nlohmann::json::parse(req.body);
+        }
+
+        ExecuteScriptRequest exec_req;
+        exec_req.workspace_id = workspace_id;
+        exec_req.script_id = script_id;
+        exec_req.executed_by = auth_user->user_id;
+        exec_req.trigger_type = "manual";
+        exec_req.trigger_event = body.value("event", nlohmann::json::object());
+
+        auto result = scriptService_->ExecuteScript(exec_req);
+        if (!result.success) {
+            res.status = 400;
+            res.set_content(nlohmann::json{{"error", result.error}}.dump(), "application/json");
+            return;
+        }
+
+        const auto& execution = *result.execution;
+        nlohmann::json response;
+        response["id"] = execution.id;
+        response["script_id"] = execution.script_id;
+        response["workspace_id"] = execution.workspace_id;
+        response["trigger_type"] = execution.trigger_type;
+        response["status"] = ScriptService::ExecStatusToString(execution.status);
+        response["result"] = execution.result;
+        response["error"] = execution.error;
+        response["logs"] = execution.logs;
+        response["started_at"] = execution.started_at;
+        response["completed_at"] = execution.completed_at;
+        response["execution_time_ms"] = execution.execution_time_ms;
+        response["executed_by"] = execution.executed_by;
+
+        res.set_content(response.dump(), "application/json");
+
+    } catch (const nlohmann::json::exception& e) {
+        res.status = 400;
+        res.set_content(nlohmann::json{{"error", "Invalid JSON: " + std::string(e.what())}}.dump(), "application/json");
+    } catch (const std::exception& e) {
+        spdlog::error("ExecuteScript error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleListScriptExecutions(const httplib::Request& req, httplib::Response& res) {
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    auto workspace_id = req.matches[1].str();
+    auto script_id = req.matches[2].str();
+
+    // Check read permission
+    auto read_all = permissions::BuildScriptPermission(workspace_id, "read_all");
+    auto read_own = permissions::BuildScriptPermission(workspace_id, "read_own");
+    bool can_read_all = authorizationService_->HasPermission(*auth_user, read_all);
+    bool can_read_own = authorizationService_->HasPermission(*auth_user, read_own);
+
+    if (!can_read_all && !can_read_own) {
+        res.status = 403;
+        res.set_content(R"({"error":"Forbidden - insufficient permissions"})", "application/json");
+        return;
+    }
+
+    try {
+        // If read_own, verify script ownership
+        if (!can_read_all) {
+            auto script_result = scriptService_->GetScript(workspace_id, script_id, false);
+            if (!script_result.success || !script_result.script || script_result.script->created_by != auth_user->user_id) {
+                res.status = 403;
+                res.set_content(R"({"error":"Forbidden - insufficient permissions"})", "application/json");
+                return;
+            }
+        }
+
+        int limit = 50;
+        int offset = 0;
+        if (req.has_param("limit")) {
+            limit = std::min(std::stoi(req.get_param_value("limit")), 100);
+        }
+        if (req.has_param("offset")) {
+            offset = std::stoi(req.get_param_value("offset"));
+        }
+
+        auto result = scriptService_->ListExecutions(workspace_id, script_id, limit, offset);
+        if (!result.success) {
+            res.status = 500;
+            res.set_content(nlohmann::json{{"error", result.error}}.dump(), "application/json");
+            return;
+        }
+
+        nlohmann::json response;
+        response["executions"] = nlohmann::json::array();
+
+        for (const auto& execution : result.executions) {
+            nlohmann::json exec_json;
+            exec_json["id"] = execution.id;
+            exec_json["script_id"] = execution.script_id;
+            exec_json["trigger_type"] = execution.trigger_type;
+            exec_json["status"] = ScriptService::ExecStatusToString(execution.status);
+            exec_json["started_at"] = execution.started_at;
+            exec_json["completed_at"] = execution.completed_at;
+            exec_json["execution_time_ms"] = execution.execution_time_ms;
+            exec_json["executed_by"] = execution.executed_by;
+            response["executions"].push_back(exec_json);
+        }
+        response["total_count"] = result.total_count;
+
+        res.set_content(response.dump(), "application/json");
+
+    } catch (const std::exception& e) {
+        spdlog::error("ListScriptExecutions error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleGetScriptExecution(const httplib::Request& req, httplib::Response& res) {
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    auto workspace_id = req.matches[1].str();
+    auto execution_id = req.matches[2].str();
+
+    // Check read permission
+    auto read_all = permissions::BuildScriptPermission(workspace_id, "read_all");
+    auto read_own = permissions::BuildScriptPermission(workspace_id, "read_own");
+    bool can_read_all = authorizationService_->HasPermission(*auth_user, read_all);
+    bool can_read_own = authorizationService_->HasPermission(*auth_user, read_own);
+
+    if (!can_read_all && !can_read_own) {
+        res.status = 403;
+        res.set_content(R"({"error":"Forbidden - insufficient permissions"})", "application/json");
+        return;
+    }
+
+    try {
+        auto result = scriptService_->GetExecution(workspace_id, execution_id);
+        if (!result.success || !result.execution) {
+            res.status = 404;
+            res.set_content(nlohmann::json{{"error", result.error.empty() ? "Execution not found" : result.error}}.dump(), "application/json");
+            return;
+        }
+
+        const auto& execution = *result.execution;
+
+        // If read_own, verify script ownership
+        if (!can_read_all) {
+            auto script_result = scriptService_->GetScript(workspace_id, execution.script_id, false);
+            if (!script_result.success || !script_result.script || script_result.script->created_by != auth_user->user_id) {
+                res.status = 403;
+                res.set_content(R"({"error":"Forbidden - insufficient permissions"})", "application/json");
+                return;
+            }
+        }
+
+        nlohmann::json response;
+        response["id"] = execution.id;
+        response["script_id"] = execution.script_id;
+        response["workspace_id"] = execution.workspace_id;
+        response["trigger_type"] = execution.trigger_type;
+        response["trigger_event"] = execution.trigger_event;
+        response["status"] = ScriptService::ExecStatusToString(execution.status);
+        response["result"] = execution.result;
+        response["error"] = execution.error;
+        response["logs"] = execution.logs;
+        response["started_at"] = execution.started_at;
+        response["completed_at"] = execution.completed_at;
+        response["execution_time_ms"] = execution.execution_time_ms;
+        response["executed_by"] = execution.executed_by;
+
+        res.set_content(response.dump(), "application/json");
+
+    } catch (const std::exception& e) {
+        spdlog::error("GetScriptExecution error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
 }  // namespace smartbotic::webserver

+ 24 - 0
webserver/src/permissions.cpp

@@ -55,6 +55,17 @@ auto BuildFieldPermission(std::string_view workspace_id,
     return result;
 }
 
+auto BuildScriptPermission(std::string_view workspace_id,
+                            std::string_view action) -> std::string {
+    std::string result;
+    result.reserve(8 + workspace_id.size() + action.size());
+    result.append("script:");
+    result.append(workspace_id);
+    result.append(":");
+    result.append(action);
+    return result;
+}
+
 auto ParsePermission(std::string_view permission) -> std::vector<std::string> {
     std::vector<std::string> parts;
     std::string part;
@@ -236,4 +247,17 @@ auto GetAllPageActions() -> std::vector<std::string_view> {
     };
 }
 
+auto GetAllScriptActions() -> std::vector<std::string_view> {
+    return {
+        "create",
+        "read_all",
+        "read_own",
+        "write_all",
+        "write_own",
+        "delete_all",
+        "delete_own",
+        "execute",
+    };
+}
+
 }  // namespace smartbotic::permissions

+ 1200 - 0
webserver/src/script_service.cpp

@@ -0,0 +1,1200 @@
+#include "smartbotic/webserver/script_service.hpp"
+
+#include <algorithm>
+#include <chrono>
+#include <iomanip>
+#include <sstream>
+#include <random>
+
+#include <spdlog/spdlog.h>
+
+#include "smartbotic/webserver/authorization_service.hpp"
+#include "smartbotic/webserver/document_service.hpp"
+#include "smartbotic/webserver/event_manager.hpp"
+#include "smartbotic/webserver/permissions.hpp"
+#include "smartbotic/runner/js_engine.hpp"
+
+namespace smartbotic::webserver {
+
+namespace {
+
+/// Get current timestamp as ISO 8601 string
+auto GetTimestamp() -> std::string {
+    auto now = std::chrono::system_clock::now();
+    auto time = std::chrono::system_clock::to_time_t(now);
+    auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
+        now.time_since_epoch()) % 1000;
+
+    std::tm tm{};
+    gmtime_r(&time, &tm);
+
+    std::ostringstream oss;
+    oss << std::put_time(&tm, "%Y-%m-%dT%H:%M:%S");
+    oss << "." << std::setfill('0') << std::setw(3) << ms.count() << "Z";
+    return oss.str();
+}
+
+/// Generate a UUID v4
+auto GenerateUuidV4() -> std::string {
+    static std::random_device rd;
+    static std::mt19937 gen(rd());
+    static std::uniform_int_distribution<> dis(0, 15);
+    static std::uniform_int_distribution<> dis2(8, 11);
+
+    std::stringstream ss;
+    ss << std::hex;
+    for (int i = 0; i < 8; i++) { ss << dis(gen); }
+    ss << "-";
+    for (int i = 0; i < 4; i++) { ss << dis(gen); }
+    ss << "-4";  // Version 4
+    for (int i = 0; i < 3; i++) { ss << dis(gen); }
+    ss << "-";
+    ss << dis2(gen);  // Variant
+    for (int i = 0; i < 3; i++) { ss << dis(gen); }
+    ss << "-";
+    for (int i = 0; i < 12; i++) { ss << dis(gen); }
+    return ss.str();
+}
+
+}  // namespace
+
+ScriptService::ScriptService(DatabaseClient& db_client) : db_client_(db_client) {}
+
+ScriptService::~ScriptService() {
+    StopScheduler();
+}
+
+void ScriptService::SetEventManager(EventManager* event_manager) {
+    event_manager_ = event_manager;
+    if (event_manager_) {
+        SubscribeToEvents();
+    }
+}
+
+void ScriptService::SetAuthorizationService(AuthorizationService* auth_service) {
+    auth_service_ = auth_service;
+}
+
+// Move operations deleted because std::atomic and std::mutex are not movable
+
+auto ScriptService::Initialize() -> bool {
+    auto* collection_service = db_client_.GetCollectionService();
+    if (collection_service == nullptr) {
+        spdlog::error("ScriptService: Collection service not available");
+        return false;
+    }
+
+    // Initialize _scripts collection
+    {
+        grpc::ClientContext ctx;
+        ::smartbotic::database::GetCollectionMetadataRequest req;
+        ::smartbotic::database::CollectionMetadata resp;
+        req.set_name(kScriptsCollection);
+        auto status = collection_service->GetCollectionMetadata(&ctx, req, &resp);
+
+        if (!status.ok() && status.error_code() == grpc::StatusCode::NOT_FOUND) {
+            grpc::ClientContext create_ctx;
+            ::smartbotic::database::CreateCollectionRequest create_req;
+            ::smartbotic::database::CollectionMetadata create_resp;
+            create_req.set_name(kScriptsCollection);
+            auto create_status = collection_service->CreateCollection(&create_ctx, create_req, &create_resp);
+            if (!create_status.ok()) {
+                spdlog::error("ScriptService: Failed to create {}: {}", kScriptsCollection, create_status.error_message());
+                return false;
+            }
+            spdlog::info("ScriptService: Created collection {}", kScriptsCollection);
+        }
+    }
+
+    // Initialize _script_executions collection
+    {
+        grpc::ClientContext ctx;
+        ::smartbotic::database::GetCollectionMetadataRequest req;
+        ::smartbotic::database::CollectionMetadata resp;
+        req.set_name(kExecutionsCollection);
+        auto status = collection_service->GetCollectionMetadata(&ctx, req, &resp);
+
+        if (!status.ok() && status.error_code() == grpc::StatusCode::NOT_FOUND) {
+            grpc::ClientContext create_ctx;
+            ::smartbotic::database::CreateCollectionRequest create_req;
+            ::smartbotic::database::CollectionMetadata create_resp;
+            create_req.set_name(kExecutionsCollection);
+            auto create_status = collection_service->CreateCollection(&create_ctx, create_req, &create_resp);
+            if (!create_status.ok()) {
+                spdlog::error("ScriptService: Failed to create {}: {}", kExecutionsCollection, create_status.error_message());
+                return false;
+            }
+            spdlog::info("ScriptService: Created collection {}", kExecutionsCollection);
+        }
+    }
+
+    // Initialize _script_storage collection
+    {
+        grpc::ClientContext ctx;
+        ::smartbotic::database::GetCollectionMetadataRequest req;
+        ::smartbotic::database::CollectionMetadata resp;
+        req.set_name(kStorageCollection);
+        auto status = collection_service->GetCollectionMetadata(&ctx, req, &resp);
+
+        if (!status.ok() && status.error_code() == grpc::StatusCode::NOT_FOUND) {
+            grpc::ClientContext create_ctx;
+            ::smartbotic::database::CreateCollectionRequest create_req;
+            ::smartbotic::database::CollectionMetadata create_resp;
+            create_req.set_name(kStorageCollection);
+            auto create_status = collection_service->CreateCollection(&create_ctx, create_req, &create_resp);
+            if (!create_status.ok()) {
+                spdlog::error("ScriptService: Failed to create {}: {}", kStorageCollection, create_status.error_message());
+                return false;
+            }
+            spdlog::info("ScriptService: Created collection {}", kStorageCollection);
+        }
+    }
+
+    spdlog::info("ScriptService: Initialized successfully");
+    return true;
+}
+
+void ScriptService::StartScheduler() {
+    if (scheduler_running_.load()) {
+        return;
+    }
+    scheduler_running_.store(true);
+    scheduler_thread_ = std::thread([this]() { SchedulerLoop(); });
+    spdlog::info("ScriptService: Cron scheduler started");
+}
+
+void ScriptService::StopScheduler() {
+    if (!scheduler_running_.load()) {
+        return;
+    }
+    scheduler_running_.store(false);
+    if (scheduler_thread_.joinable()) {
+        scheduler_thread_.join();
+    }
+    spdlog::info("ScriptService: Cron scheduler stopped");
+}
+
+void ScriptService::SubscribeToEvents() {
+    if (event_manager_ == nullptr) {
+        return;
+    }
+
+    event_listener_id_ = event_manager_->AddListener([this](const EntityEvent& event) {
+        OnEntityEvent(event);
+    });
+    spdlog::debug("ScriptService: Subscribed to entity events");
+}
+
+// === CRUD Operations ===
+
+auto ScriptService::CreateScript(const CreateScriptRequest& request) -> ScriptResult {
+    ScriptInfo script;
+    script.id = GenerateUuidV4();
+    script.workspace_id = request.workspace_id;
+    script.name = request.name;
+    script.description = request.description;
+    script.code = request.code;
+    script.status = ScriptStatus::Draft;
+    script.config = request.config;
+    script.triggers = request.triggers;
+    script.created_at = GetTimestamp();
+    script.updated_at = script.created_at;
+    script.created_by = request.created_by;
+
+    // Assign IDs to triggers if not set
+    for (auto& trigger : script.triggers) {
+        if (trigger.id.empty()) {
+            trigger.id = GenerateUuidV4();
+        }
+    }
+
+    // Validate config
+    if (script.config.timeout_ms > 30000) {
+        script.config.timeout_ms = 30000;
+    }
+    if (script.config.max_memory_mb > 64) {
+        script.config.max_memory_mb = 64;
+    }
+
+    auto* doc_service = db_client_.GetDocumentService();
+    if (doc_service == nullptr) {
+        return {.success = false, .error = "Document service not available", .script = std::nullopt};
+    }
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::CreateDocumentRequest req;
+    ::smartbotic::database::Document resp;
+
+    req.set_collection(kScriptsCollection);
+    req.set_id(script.id);
+    *req.mutable_data() = DocumentService::JsonToMapValue(ScriptToJson(script));
+
+    auto status = doc_service->CreateDocument(&ctx, req, &resp);
+    if (!status.ok()) {
+        spdlog::error("ScriptService: Failed to create script: {}", status.error_message());
+        return {.success = false, .error = status.error_message(), .script = std::nullopt};
+    }
+
+    spdlog::info("ScriptService: Created script {} in workspace {}", script.id, script.workspace_id);
+
+    // Emit event
+    if (event_manager_) {
+        event_manager_->EmitScriptEvent(EventAction::Create, script.id, script.workspace_id,
+                                        ScriptToJson(script), script.created_by);
+    }
+
+    return {.success = true, .error = "", .script = script};
+}
+
+auto ScriptService::GetScript(const std::string& workspace_id, const std::string& id,
+                              bool include_deleted) -> ScriptResult {
+    auto* doc_service = db_client_.GetDocumentService();
+    if (doc_service == nullptr) {
+        return {.success = false, .error = "Document service not available", .script = std::nullopt};
+    }
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::GetDocumentRequest req;
+    ::smartbotic::database::Document resp;
+
+    req.set_collection(kScriptsCollection);
+    req.set_id(id);
+
+    auto status = doc_service->GetDocument(&ctx, req, &resp);
+    if (!status.ok()) {
+        if (status.error_code() == grpc::StatusCode::NOT_FOUND) {
+            return {.success = false, .error = "Script not found", .script = std::nullopt};
+        }
+        return {.success = false, .error = status.error_message(), .script = std::nullopt};
+    }
+
+    auto json = DocumentService::MapValueToJson(resp.data());
+    json["id"] = resp.id();
+
+    auto script = JsonToScript(json);
+
+    // Check workspace match
+    if (script.workspace_id != workspace_id) {
+        return {.success = false, .error = "Script not found", .script = std::nullopt};
+    }
+
+    // Check if deleted
+    if (script.IsDeleted() && !include_deleted) {
+        return {.success = false, .error = "Script not found", .script = std::nullopt};
+    }
+
+    return {.success = true, .error = "", .script = script};
+}
+
+auto ScriptService::ListScripts(const std::string& workspace_id,
+                                bool include_deleted) -> ScriptListResult {
+    auto* query_service = db_client_.GetQueryService();
+    if (query_service == nullptr) {
+        return {.success = false, .error = "Query service not available", .scripts = {}, .total_count = 0};
+    }
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::QueryRequest req;
+    ::smartbotic::database::QueryResponse resp;
+
+    req.set_collection(kScriptsCollection);
+    req.set_limit(1000);
+
+    // Build composite filter for workspace_id and deleted_at
+    auto* filter = req.mutable_filter();
+    auto* composite = filter->mutable_composite();
+    composite->set_operator_(::smartbotic::database::COMPOSITE_OPERATOR_AND);
+
+    auto* ws_filter = composite->add_filters()->mutable_field();
+    ws_filter->set_field("workspace_id");
+    ws_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+    ws_filter->mutable_value()->set_string_value(workspace_id);
+
+    if (!include_deleted) {
+        auto* deleted_filter = composite->add_filters()->mutable_field();
+        deleted_filter->set_field("deleted_at");
+        deleted_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+        deleted_filter->mutable_value()->set_string_value("");
+    }
+
+    auto status = query_service->Query(&ctx, req, &resp);
+    if (!status.ok()) {
+        return {.success = false, .error = status.error_message(), .scripts = {}, .total_count = 0};
+    }
+
+    std::vector<ScriptInfo> scripts;
+    for (const auto& doc : resp.documents()) {
+        auto json = DocumentService::MapValueToJson(doc.data());
+        json["id"] = doc.id();
+        scripts.push_back(JsonToScript(json));
+    }
+
+    // Sort by updated_at descending
+    std::sort(scripts.begin(), scripts.end(), [](const auto& a, const auto& b) {
+        return a.updated_at > b.updated_at;
+    });
+
+    return {.success = true, .error = "", .scripts = scripts, .total_count = static_cast<int64_t>(scripts.size())};
+}
+
+auto ScriptService::UpdateScript(const UpdateScriptRequest& request) -> ScriptResult {
+    auto existing = GetScript(request.workspace_id, request.id, false);
+    if (!existing.success || !existing.script) {
+        return {.success = false, .error = existing.error.empty() ? "Script not found" : existing.error, .script = std::nullopt};
+    }
+
+    auto script = *existing.script;
+
+    // Apply updates
+    if (request.name) {
+        script.name = *request.name;
+    }
+    if (request.description) {
+        script.description = *request.description;
+    }
+    if (request.code) {
+        script.code = *request.code;
+    }
+    if (request.status) {
+        script.status = *request.status;
+    }
+    if (request.config) {
+        script.config = *request.config;
+        // Validate limits
+        if (script.config.timeout_ms > 30000) {
+            script.config.timeout_ms = 30000;
+        }
+        if (script.config.max_memory_mb > 64) {
+            script.config.max_memory_mb = 64;
+        }
+    }
+    if (request.triggers) {
+        script.triggers = *request.triggers;
+        // Assign IDs to new triggers
+        for (auto& trigger : script.triggers) {
+            if (trigger.id.empty()) {
+                trigger.id = GenerateUuidV4();
+            }
+        }
+    }
+
+    script.updated_at = GetTimestamp();
+
+    auto* doc_service = db_client_.GetDocumentService();
+    if (doc_service == nullptr) {
+        return {.success = false, .error = "Document service not available", .script = std::nullopt};
+    }
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::UpdateDocumentRequest req;
+    ::smartbotic::database::Document resp;
+
+    req.set_collection(kScriptsCollection);
+    req.set_id(script.id);
+    *req.mutable_data() = DocumentService::JsonToMapValue(ScriptToJson(script));
+
+    auto status = doc_service->UpdateDocument(&ctx, req, &resp);
+    if (!status.ok()) {
+        return {.success = false, .error = status.error_message(), .script = std::nullopt};
+    }
+
+    spdlog::info("ScriptService: Updated script {} in workspace {}", script.id, script.workspace_id);
+
+    // Update cron schedule if triggers changed
+    if (request.triggers || request.status) {
+        UpdateCronSchedule(script);
+    }
+
+    // Emit event
+    if (event_manager_) {
+        event_manager_->EmitScriptEvent(EventAction::Update, script.id, script.workspace_id,
+                                        ScriptToJson(script), script.created_by);
+    }
+
+    return {.success = true, .error = "", .script = script};
+}
+
+auto ScriptService::DeleteScript(const std::string& workspace_id, const std::string& id) -> ScriptResult {
+    auto existing = GetScript(workspace_id, id, false);
+    if (!existing.success || !existing.script) {
+        return {.success = false, .error = existing.error.empty() ? "Script not found" : existing.error, .script = std::nullopt};
+    }
+
+    auto script = *existing.script;
+    script.deleted_at = GetTimestamp();
+    script.updated_at = script.deleted_at;
+
+    auto* doc_service = db_client_.GetDocumentService();
+    if (doc_service == nullptr) {
+        return {.success = false, .error = "Document service not available", .script = std::nullopt};
+    }
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::UpdateDocumentRequest req;
+    ::smartbotic::database::Document resp;
+
+    req.set_collection(kScriptsCollection);
+    req.set_id(script.id);
+    *req.mutable_data() = DocumentService::JsonToMapValue(ScriptToJson(script));
+
+    auto status = doc_service->UpdateDocument(&ctx, req, &resp);
+    if (!status.ok()) {
+        return {.success = false, .error = status.error_message(), .script = std::nullopt};
+    }
+
+    // Remove from cron schedule
+    RemoveCronSchedule(script.id);
+
+    spdlog::info("ScriptService: Deleted script {} in workspace {}", script.id, script.workspace_id);
+
+    // Emit event
+    if (event_manager_) {
+        event_manager_->EmitScriptEvent(EventAction::Delete, script.id, script.workspace_id,
+                                        ScriptToJson(script), script.created_by);
+    }
+
+    return {.success = true, .error = "", .script = script};
+}
+
+// === Execution Operations ===
+
+auto ScriptService::ExecuteScript(const ExecuteScriptRequest& request) -> ScriptExecutionResult {
+    // Get the script
+    auto script_result = GetScript(request.workspace_id, request.script_id, false);
+    if (!script_result.success || !script_result.script) {
+        return {.success = false, .error = "Script not found", .execution = std::nullopt};
+    }
+
+    const auto& script = *script_result.script;
+
+    // Check if script is active (unless manual execution)
+    if (request.trigger_type != "manual" && script.status != ScriptStatus::Active) {
+        return {.success = false, .error = "Script is not active", .execution = std::nullopt};
+    }
+
+    // Create execution record
+    ScriptExecution execution;
+    execution.id = GenerateUuidV4();
+    execution.script_id = script.id;
+    execution.workspace_id = script.workspace_id;
+    execution.trigger_type = request.trigger_type;
+    execution.trigger_event = request.trigger_event;
+    execution.status = ExecutionStatus::Running;
+    execution.started_at = GetTimestamp();
+    execution.executed_by = request.executed_by.empty() ? script.created_by : request.executed_by;
+
+    // Save execution record
+    auto* doc_service = db_client_.GetDocumentService();
+    if (doc_service != nullptr) {
+        grpc::ClientContext ctx;
+        ::smartbotic::database::CreateDocumentRequest req;
+        ::smartbotic::database::Document resp;
+        req.set_collection(kExecutionsCollection);
+        req.set_id(execution.id);
+        *req.mutable_data() = DocumentService::JsonToMapValue(ExecutionToJson(execution));
+        doc_service->CreateDocument(&ctx, req, &resp);
+    }
+
+    // Emit execution start event
+    if (event_manager_) {
+        event_manager_->EmitScriptExecutionEvent(EventAction::Create, execution.id,
+                                                  execution.workspace_id, ExecutionToJson(execution),
+                                                  execution.executed_by);
+    }
+
+    // Set up JavaScript engine
+    smartbotic::runner::JsEngineConfig js_config;
+    js_config.max_memory = script.config.max_memory_mb * 1024 * 1024;
+    js_config.max_execution_time_ms = script.config.timeout_ms;
+    js_config.max_db_queries = 100;
+    js_config.allow_eval = false;
+
+    smartbotic::runner::JsEngine engine(js_config);
+
+    // Set up context
+    smartbotic::runner::JsContext js_ctx;
+    js_ctx.workspace_id = script.workspace_id;
+    js_ctx.user_id = execution.executed_by;
+
+    // Add event data to context if available
+    if (!request.trigger_event.is_null()) {
+        js_ctx.custom["event"] = request.trigger_event.dump();
+    }
+
+    engine.SetContext(js_ctx);
+
+    // Set up database callbacks with permission checks
+    const std::string creator_id = script.created_by;
+    const std::string ws_id = script.workspace_id;
+    const std::vector<std::string> allowed_collections = script.config.allowed_collections;
+
+    engine.SetDbCallbacks(
+        // Query callback
+        [this, creator_id, ws_id, allowed_collections](const std::string& collection,
+                                                        const std::string& filter_json,
+                                                        int limit, int offset) -> std::string {
+            // Check if collection is allowed
+            if (!allowed_collections.empty()) {
+                auto it = std::find(allowed_collections.begin(), allowed_collections.end(), collection);
+                if (it == allowed_collections.end()) {
+                    spdlog::warn("ScriptService: Access denied to collection {}", collection);
+                    return "[]";
+                }
+            }
+
+            // Check creator's permission
+            if (!CreatorCanAccessCollection(creator_id, ws_id, collection, "read_all")) {
+                spdlog::warn("ScriptService: Creator {} lacks read permission for {}", creator_id, collection);
+                return "[]";
+            }
+
+            auto* query_service = db_client_.GetQueryService();
+            if (query_service == nullptr) { return "[]"; }
+
+            grpc::ClientContext ctx;
+            ::smartbotic::database::QueryRequest req;
+            ::smartbotic::database::QueryResponse resp;
+            req.set_collection(collection);
+            req.set_limit(limit);
+            req.set_offset(offset);
+
+            // Parse filter JSON and build structured filter
+            if (!filter_json.empty() && filter_json != "{}") {
+                auto filter_obj = nlohmann::json::parse(filter_json, nullptr, false);
+                if (!filter_obj.is_discarded() && filter_obj.is_object() && !filter_obj.empty()) {
+                    auto* filter = req.mutable_filter();
+                    auto* composite = filter->mutable_composite();
+                    composite->set_operator_(::smartbotic::database::COMPOSITE_OPERATOR_AND);
+
+                    for (auto& [key, value] : filter_obj.items()) {
+                        auto* field_filter = composite->add_filters()->mutable_field();
+                        field_filter->set_field(key);
+                        field_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+                        if (value.is_string()) {
+                            field_filter->mutable_value()->set_string_value(value.get<std::string>());
+                        } else if (value.is_number_integer()) {
+                            field_filter->mutable_value()->set_int_value(value.get<int64_t>());
+                        } else if (value.is_boolean()) {
+                            field_filter->mutable_value()->set_bool_value(value.get<bool>());
+                        } else if (value.is_number_float()) {
+                            field_filter->mutable_value()->set_double_value(value.get<double>());
+                        }
+                    }
+                }
+            }
+
+            auto status = query_service->Query(&ctx, req, &resp);
+            if (!status.ok()) { return "[]"; }
+
+            nlohmann::json results = nlohmann::json::array();
+            for (const auto& doc : resp.documents()) {
+                auto json = DocumentService::MapValueToJson(doc.data());
+                json["_id"] = doc.id();
+                results.push_back(json);
+            }
+            return results.dump();
+        },
+        // Get callback
+        [this, creator_id, ws_id, allowed_collections](const std::string& collection,
+                                                        const std::string& id) -> std::string {
+            if (!allowed_collections.empty()) {
+                auto it = std::find(allowed_collections.begin(), allowed_collections.end(), collection);
+                if (it == allowed_collections.end()) {
+                    return "";
+                }
+            }
+
+            if (!CreatorCanAccessCollection(creator_id, ws_id, collection, "read_all")) {
+                return "";
+            }
+
+            auto* doc_service = db_client_.GetDocumentService();
+            if (doc_service == nullptr) { return ""; }
+
+            grpc::ClientContext ctx;
+            ::smartbotic::database::GetDocumentRequest req;
+            ::smartbotic::database::Document resp;
+            req.set_collection(collection);
+            req.set_id(id);
+
+            auto status = doc_service->GetDocument(&ctx, req, &resp);
+            if (!status.ok()) { return ""; }
+
+            auto json = DocumentService::MapValueToJson(resp.data());
+            json["_id"] = resp.id();
+            return json.dump();
+        },
+        // Count callback
+        [this, creator_id, ws_id, allowed_collections](const std::string& collection,
+                                                        const std::string& filter_json) -> int64_t {
+            if (!allowed_collections.empty()) {
+                auto it = std::find(allowed_collections.begin(), allowed_collections.end(), collection);
+                if (it == allowed_collections.end()) {
+                    return 0;
+                }
+            }
+
+            if (!CreatorCanAccessCollection(creator_id, ws_id, collection, "read_all")) {
+                return 0;
+            }
+
+            auto* query_service = db_client_.GetQueryService();
+            if (query_service == nullptr) { return 0; }
+
+            grpc::ClientContext ctx;
+            ::smartbotic::database::CountRequest req;
+            ::smartbotic::database::CountResponse resp;
+            req.set_collection(collection);
+
+            // Parse filter JSON and build structured filter
+            if (!filter_json.empty() && filter_json != "{}") {
+                auto filter_obj = nlohmann::json::parse(filter_json, nullptr, false);
+                if (!filter_obj.is_discarded() && filter_obj.is_object() && !filter_obj.empty()) {
+                    auto* filter = req.mutable_filter();
+                    auto* composite = filter->mutable_composite();
+                    composite->set_operator_(::smartbotic::database::COMPOSITE_OPERATOR_AND);
+
+                    for (auto& [key, value] : filter_obj.items()) {
+                        auto* field_filter = composite->add_filters()->mutable_field();
+                        field_filter->set_field(key);
+                        field_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+                        if (value.is_string()) {
+                            field_filter->mutable_value()->set_string_value(value.get<std::string>());
+                        } else if (value.is_number_integer()) {
+                            field_filter->mutable_value()->set_int_value(value.get<int64_t>());
+                        } else if (value.is_boolean()) {
+                            field_filter->mutable_value()->set_bool_value(value.get<bool>());
+                        } else if (value.is_number_float()) {
+                            field_filter->mutable_value()->set_double_value(value.get<double>());
+                        }
+                    }
+                }
+            }
+
+            auto status = query_service->Count(&ctx, req, &resp);
+            if (!status.ok()) { return 0; }
+            return resp.count();
+        }
+    );
+
+    // Execute the script
+    auto start_time = std::chrono::steady_clock::now();
+    auto result = engine.Execute(script.code);
+    auto end_time = std::chrono::steady_clock::now();
+
+    execution.execution_time_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
+        end_time - start_time).count();
+
+    if (result.success) {
+        execution.status = ExecutionStatus::Success;
+        execution.result = result.result;
+    } else {
+        if (result.error.find("timeout") != std::string::npos ||
+            result.error.find("interrupt") != std::string::npos) {
+            execution.status = ExecutionStatus::Timeout;
+        } else {
+            execution.status = ExecutionStatus::Error;
+        }
+        execution.error = result.error;
+    }
+
+    execution.completed_at = GetTimestamp();
+
+    // Update execution record
+    if (doc_service != nullptr) {
+        grpc::ClientContext ctx;
+        ::smartbotic::database::UpdateDocumentRequest req;
+        ::smartbotic::database::Document resp;
+        req.set_collection(kExecutionsCollection);
+        req.set_id(execution.id);
+        *req.mutable_data() = DocumentService::JsonToMapValue(ExecutionToJson(execution));
+        doc_service->UpdateDocument(&ctx, req, &resp);
+    }
+
+    // Emit execution complete event
+    if (event_manager_) {
+        event_manager_->EmitScriptExecutionEvent(EventAction::Update, execution.id,
+                                                  execution.workspace_id, ExecutionToJson(execution),
+                                                  execution.executed_by);
+    }
+
+    spdlog::info("ScriptService: Executed script {} with status {}", script.id,
+                 ExecStatusToString(execution.status));
+
+    return {.success = true, .error = "", .execution = execution};
+}
+
+auto ScriptService::GetExecution(const std::string& workspace_id,
+                                 const std::string& execution_id) -> ScriptExecutionResult {
+    auto* doc_service = db_client_.GetDocumentService();
+    if (doc_service == nullptr) {
+        return {.success = false, .error = "Document service not available", .execution = std::nullopt};
+    }
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::GetDocumentRequest req;
+    ::smartbotic::database::Document resp;
+
+    req.set_collection(kExecutionsCollection);
+    req.set_id(execution_id);
+
+    auto status = doc_service->GetDocument(&ctx, req, &resp);
+    if (!status.ok()) {
+        if (status.error_code() == grpc::StatusCode::NOT_FOUND) {
+            return {.success = false, .error = "Execution not found", .execution = std::nullopt};
+        }
+        return {.success = false, .error = status.error_message(), .execution = std::nullopt};
+    }
+
+    auto json = DocumentService::MapValueToJson(resp.data());
+    json["id"] = resp.id();
+
+    auto execution = JsonToExecution(json);
+
+    if (execution.workspace_id != workspace_id) {
+        return {.success = false, .error = "Execution not found", .execution = std::nullopt};
+    }
+
+    return {.success = true, .error = "", .execution = execution};
+}
+
+auto ScriptService::ListExecutions(const std::string& workspace_id,
+                                   const std::string& script_id,
+                                   int limit, int offset) -> ExecutionListResult {
+    auto* query_service = db_client_.GetQueryService();
+    if (query_service == nullptr) {
+        return {.success = false, .error = "Query service not available", .executions = {}, .total_count = 0};
+    }
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::QueryRequest req;
+    ::smartbotic::database::QueryResponse resp;
+
+    req.set_collection(kExecutionsCollection);
+    req.set_limit(limit);
+    req.set_offset(offset);
+
+    // Build composite filter for workspace_id and script_id
+    auto* filter = req.mutable_filter();
+    auto* composite = filter->mutable_composite();
+    composite->set_operator_(::smartbotic::database::COMPOSITE_OPERATOR_AND);
+
+    auto* ws_filter = composite->add_filters()->mutable_field();
+    ws_filter->set_field("workspace_id");
+    ws_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+    ws_filter->mutable_value()->set_string_value(workspace_id);
+
+    auto* script_filter = composite->add_filters()->mutable_field();
+    script_filter->set_field("script_id");
+    script_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+    script_filter->mutable_value()->set_string_value(script_id);
+
+    auto status = query_service->Query(&ctx, req, &resp);
+    if (!status.ok()) {
+        return {.success = false, .error = status.error_message(), .executions = {}, .total_count = 0};
+    }
+
+    std::vector<ScriptExecution> executions;
+    for (const auto& doc : resp.documents()) {
+        auto json = DocumentService::MapValueToJson(doc.data());
+        json["id"] = doc.id();
+        executions.push_back(JsonToExecution(json));
+    }
+
+    // Sort by started_at descending
+    std::sort(executions.begin(), executions.end(), [](const auto& a, const auto& b) {
+        return a.started_at > b.started_at;
+    });
+
+    return {.success = true, .error = "", .executions = executions, .total_count = resp.total_count()};
+}
+
+// === Event Handling ===
+
+void ScriptService::OnEntityEvent(const EntityEvent& event) {
+    // Only handle Document events for now
+    if (event.entity_type != EntityType::Document) {
+        return;
+    }
+
+    auto scripts = FindTriggeredScripts(event);
+    for (const auto& script : scripts) {
+        // Execute script asynchronously
+        ExecuteScriptRequest exec_req;
+        exec_req.workspace_id = script.workspace_id;
+        exec_req.script_id = script.id;
+        exec_req.executed_by = script.created_by;
+        exec_req.trigger_type = "event";
+        exec_req.trigger_event = {
+            {"entity_type", EntityTypeToString(event.entity_type)},
+            {"action", EventActionToString(event.action)},
+            {"entity_id", event.entity_id},
+            {"collection", event.collection_name},
+            {"data", event.data}
+        };
+
+        // Execute in background thread to not block event processing
+        std::thread([this, exec_req]() {
+            (void)ExecuteScript(exec_req);  // Discard result intentionally
+        }).detach();
+    }
+}
+
+auto ScriptService::FindTriggeredScripts(const EntityEvent& event) -> std::vector<ScriptInfo> {
+    std::vector<ScriptInfo> triggered;
+
+    // List all active scripts in the workspace
+    auto scripts_result = ListScripts(event.workspace_id, false);
+    if (!scripts_result.success) {
+        return triggered;
+    }
+
+    std::string event_action = EventActionToString(event.action);
+    std::string entity_type = EntityTypeToString(event.entity_type);
+
+    for (const auto& script : scripts_result.scripts) {
+        if (script.status != ScriptStatus::Active) {
+            continue;
+        }
+
+        // Check triggers
+        for (const auto& trigger : script.triggers) {
+            if (trigger.type != TriggerType::Event) {
+                continue;
+            }
+
+            // Match entity type
+            if (!trigger.entity_type.empty() && trigger.entity_type != entity_type) {
+                continue;
+            }
+
+            // Match action
+            if (!trigger.event_action.empty() && trigger.event_action != event_action) {
+                continue;
+            }
+
+            // Match collection filter
+            if (!trigger.collection_filter.empty() && trigger.collection_filter != event.collection_name) {
+                continue;
+            }
+
+            // All conditions match
+            triggered.push_back(script);
+            break;  // Don't trigger same script multiple times for same event
+        }
+    }
+
+    return triggered;
+}
+
+// === Cron Scheduler ===
+
+void ScriptService::SchedulerLoop() {
+    while (scheduler_running_.load()) {
+        CheckCronTriggers();
+        std::this_thread::sleep_for(std::chrono::seconds(60));
+    }
+}
+
+void ScriptService::UpdateCronSchedule(const ScriptInfo& script) {
+    std::lock_guard<std::mutex> lock(schedule_mutex_);
+
+    // Remove existing entries for this script
+    cron_schedules_.erase(
+        std::remove_if(cron_schedules_.begin(), cron_schedules_.end(),
+                       [&script](const auto& entry) { return entry.script_id == script.id; }),
+        cron_schedules_.end());
+
+    // Only add if script is active
+    if (script.status != ScriptStatus::Active) {
+        return;
+    }
+
+    // Add new cron triggers
+    for (const auto& trigger : script.triggers) {
+        if (trigger.type != TriggerType::Cron || trigger.cron_expression.empty()) {
+            continue;
+        }
+
+        CronScheduleEntry entry;
+        entry.script_id = script.id;
+        entry.workspace_id = script.workspace_id;
+        entry.cron_expression = trigger.cron_expression;
+        entry.timezone = trigger.timezone.empty() ? "UTC" : trigger.timezone;
+        entry.trigger_id = trigger.id;
+        entry.next_run = CalculateNextRun(entry.cron_expression, entry.timezone);
+
+        cron_schedules_.push_back(entry);
+    }
+}
+
+void ScriptService::RemoveCronSchedule(const std::string& script_id) {
+    std::lock_guard<std::mutex> lock(schedule_mutex_);
+    cron_schedules_.erase(
+        std::remove_if(cron_schedules_.begin(), cron_schedules_.end(),
+                       [&script_id](const auto& entry) { return entry.script_id == script_id; }),
+        cron_schedules_.end());
+}
+
+void ScriptService::CheckCronTriggers() {
+    auto now = std::chrono::system_clock::now();
+    std::vector<CronScheduleEntry> due_entries;
+
+    {
+        std::lock_guard<std::mutex> lock(schedule_mutex_);
+        for (auto& entry : cron_schedules_) {
+            if (entry.next_run <= now) {
+                due_entries.push_back(entry);
+                // Update next run time
+                entry.next_run = CalculateNextRun(entry.cron_expression, entry.timezone);
+            }
+        }
+    }
+
+    // Execute due scripts
+    for (const auto& entry : due_entries) {
+        ExecuteScriptRequest exec_req;
+        exec_req.workspace_id = entry.workspace_id;
+        exec_req.script_id = entry.script_id;
+        exec_req.trigger_type = "cron";
+        exec_req.trigger_event = {
+            {"trigger_id", entry.trigger_id},
+            {"cron_expression", entry.cron_expression},
+            {"scheduled_time", std::chrono::system_clock::to_time_t(entry.next_run)}
+        };
+
+        // Execute asynchronously
+        std::thread([this, exec_req]() {
+            (void)ExecuteScript(exec_req);  // Discard result intentionally
+        }).detach();
+    }
+}
+
+auto ScriptService::CalculateNextRun(const std::string& cron_expression,
+                                     const std::string& /*timezone*/) -> std::chrono::system_clock::time_point {
+    // Simple cron parser for basic expressions: minute hour day month weekday
+    // For now, just add 1 minute for simplicity - a full cron parser would be more complex
+    // TODO: Implement full cron expression parsing
+    (void)cron_expression;
+    return std::chrono::system_clock::now() + std::chrono::minutes(1);
+}
+
+auto ScriptService::CreatorCanAccessCollection(const std::string& creator_id,
+                                               const std::string& workspace_id,
+                                               const std::string& collection,
+                                               const std::string& action) -> bool {
+    if (auth_service_ == nullptr) {
+        // No auth service - allow access
+        return true;
+    }
+
+    // Build an AuthUser for the creator
+    AuthUser creator;
+    creator.user_id = creator_id;
+
+    // Build the permission to check
+    auto permission = permissions::BuildCollectionPermission(workspace_id, collection, action);
+
+    return auth_service_->HasPermission(creator, permission);
+}
+
+// === JSON Conversion ===
+
+auto ScriptService::ScriptToJson(const ScriptInfo& script) -> nlohmann::json {
+    nlohmann::json triggers_json = nlohmann::json::array();
+    for (const auto& trigger : script.triggers) {
+        triggers_json.push_back(TriggerToJson(trigger));
+    }
+
+    return {
+        {"id", script.id},
+        {"workspace_id", script.workspace_id},
+        {"name", script.name},
+        {"description", script.description},
+        {"code", script.code},
+        {"status", StatusToString(script.status)},
+        {"config", ConfigToJson(script.config)},
+        {"triggers", triggers_json},
+        {"created_at", script.created_at},
+        {"updated_at", script.updated_at},
+        {"created_by", script.created_by},
+        {"deleted_at", script.deleted_at}
+    };
+}
+
+auto ScriptService::JsonToScript(const nlohmann::json& json) -> ScriptInfo {
+    ScriptInfo script;
+    script.id = json.value("id", "");
+    script.workspace_id = json.value("workspace_id", "");
+    script.name = json.value("name", "");
+    script.description = json.value("description", "");
+    script.code = json.value("code", "");
+    script.status = StringToStatus(json.value("status", "draft"));
+    script.created_at = json.value("created_at", "");
+    script.updated_at = json.value("updated_at", "");
+    script.created_by = json.value("created_by", "");
+    script.deleted_at = json.value("deleted_at", "");
+
+    if (json.contains("config") && json["config"].is_object()) {
+        script.config = JsonToConfig(json["config"]);
+    }
+
+    if (json.contains("triggers") && json["triggers"].is_array()) {
+        for (const auto& t : json["triggers"]) {
+            script.triggers.push_back(JsonToTrigger(t));
+        }
+    }
+
+    return script;
+}
+
+auto ScriptService::ExecutionToJson(const ScriptExecution& execution) -> nlohmann::json {
+    return {
+        {"id", execution.id},
+        {"script_id", execution.script_id},
+        {"workspace_id", execution.workspace_id},
+        {"trigger_type", execution.trigger_type},
+        {"trigger_event", execution.trigger_event},
+        {"status", ExecStatusToString(execution.status)},
+        {"result", execution.result},
+        {"error", execution.error},
+        {"logs", execution.logs},
+        {"started_at", execution.started_at},
+        {"completed_at", execution.completed_at},
+        {"execution_time_ms", execution.execution_time_ms},
+        {"executed_by", execution.executed_by}
+    };
+}
+
+auto ScriptService::JsonToExecution(const nlohmann::json& json) -> ScriptExecution {
+    ScriptExecution execution;
+    execution.id = json.value("id", "");
+    execution.script_id = json.value("script_id", "");
+    execution.workspace_id = json.value("workspace_id", "");
+    execution.trigger_type = json.value("trigger_type", "");
+    execution.trigger_event = json.value("trigger_event", nlohmann::json::object());
+    execution.status = StringToExecStatus(json.value("status", "running"));
+    execution.result = json.value("result", "");
+    execution.error = json.value("error", "");
+    execution.started_at = json.value("started_at", "");
+    execution.completed_at = json.value("completed_at", "");
+    execution.execution_time_ms = json.value("execution_time_ms", 0);
+    execution.executed_by = json.value("executed_by", "");
+
+    if (json.contains("logs") && json["logs"].is_array()) {
+        for (const auto& log : json["logs"]) {
+            execution.logs.push_back(log.get<std::string>());
+        }
+    }
+
+    return execution;
+}
+
+auto ScriptService::TriggerToJson(const ScriptTrigger& trigger) -> nlohmann::json {
+    return {
+        {"id", trigger.id},
+        {"type", TriggerTypeToString(trigger.type)},
+        {"entity_type", trigger.entity_type},
+        {"event_action", trigger.event_action},
+        {"collection_filter", trigger.collection_filter},
+        {"cron_expression", trigger.cron_expression},
+        {"timezone", trigger.timezone},
+        {"last_triggered", trigger.last_triggered},
+        {"trigger_count", trigger.trigger_count}
+    };
+}
+
+auto ScriptService::JsonToTrigger(const nlohmann::json& json) -> ScriptTrigger {
+    ScriptTrigger trigger;
+    trigger.id = json.value("id", "");
+    trigger.type = StringToTriggerType(json.value("type", "event"));
+    trigger.entity_type = json.value("entity_type", "");
+    trigger.event_action = json.value("event_action", "");
+    trigger.collection_filter = json.value("collection_filter", "");
+    trigger.cron_expression = json.value("cron_expression", "");
+    trigger.timezone = json.value("timezone", "UTC");
+    trigger.last_triggered = json.value("last_triggered", "");
+    trigger.trigger_count = json.value("trigger_count", 0);
+    return trigger;
+}
+
+auto ScriptService::ConfigToJson(const ScriptConfig& config) -> nlohmann::json {
+    return {
+        {"timeout_ms", config.timeout_ms},
+        {"max_memory_mb", config.max_memory_mb},
+        {"allow_network", config.allow_network},
+        {"allow_storage", config.allow_storage},
+        {"allowed_collections", config.allowed_collections}
+    };
+}
+
+auto ScriptService::JsonToConfig(const nlohmann::json& json) -> ScriptConfig {
+    ScriptConfig config;
+    config.timeout_ms = json.value("timeout_ms", 5000);
+    config.max_memory_mb = json.value("max_memory_mb", 16);
+    config.allow_network = json.value("allow_network", false);
+    config.allow_storage = json.value("allow_storage", true);
+
+    if (json.contains("allowed_collections") && json["allowed_collections"].is_array()) {
+        for (const auto& c : json["allowed_collections"]) {
+            config.allowed_collections.push_back(c.get<std::string>());
+        }
+    }
+
+    return config;
+}
+
+auto ScriptService::StatusToString(ScriptStatus status) -> std::string {
+    switch (status) {
+        case ScriptStatus::Active:   return "active";
+        case ScriptStatus::Inactive: return "inactive";
+        case ScriptStatus::Draft:    return "draft";
+        default:                     return "draft";
+    }
+}
+
+auto ScriptService::StringToStatus(const std::string& str) -> ScriptStatus {
+    if (str == "active") { return ScriptStatus::Active; }
+    if (str == "inactive") { return ScriptStatus::Inactive; }
+    return ScriptStatus::Draft;
+}
+
+auto ScriptService::ExecStatusToString(ExecutionStatus status) -> std::string {
+    switch (status) {
+        case ExecutionStatus::Running: return "running";
+        case ExecutionStatus::Success: return "success";
+        case ExecutionStatus::Error:   return "error";
+        case ExecutionStatus::Timeout: return "timeout";
+        default:                       return "running";
+    }
+}
+
+auto ScriptService::StringToExecStatus(const std::string& str) -> ExecutionStatus {
+    if (str == "success") { return ExecutionStatus::Success; }
+    if (str == "error") { return ExecutionStatus::Error; }
+    if (str == "timeout") { return ExecutionStatus::Timeout; }
+    return ExecutionStatus::Running;
+}
+
+auto ScriptService::TriggerTypeToString(TriggerType type) -> std::string {
+    switch (type) {
+        case TriggerType::Event: return "event";
+        case TriggerType::Cron:  return "cron";
+        default:                 return "event";
+    }
+}
+
+auto ScriptService::StringToTriggerType(const std::string& str) -> TriggerType {
+    if (str == "cron") { return TriggerType::Cron; }
+    return TriggerType::Event;
+}
+
+auto ScriptService::GetCurrentTimestamp() -> std::string {
+    return GetTimestamp();
+}
+
+auto ScriptService::GenerateUuid() -> std::string {
+    return GenerateUuidV4();
+}
+
+}  // namespace smartbotic::webserver

+ 5 - 0
webui/src/App.tsx

@@ -17,6 +17,8 @@ import WorkspaceEdit from '@/pages/WorkspaceEdit'
 import LlmProviders from '@/pages/LlmProviders'
 import ChatPage from '@/pages/ChatPage'
 import Archives from '@/pages/Archives'
+import Scripts from '@/pages/Scripts'
+import ScriptEdit from '@/pages/ScriptEdit'
 import ProtectedRoute from '@/components/ProtectedRoute'
 import { DashboardLayout } from '@/components/layout'
 
@@ -111,6 +113,9 @@ function App() {
         <Route path="/llm-providers" element={<LlmProviders />} />
         <Route path="/chat" element={<ChatPage />} />
         <Route path="/archives" element={<Archives />} />
+        <Route path="/scripts" element={<Scripts />} />
+        <Route path="/scripts/new" element={<ScriptEdit />} />
+        <Route path="/scripts/:id/edit" element={<ScriptEdit />} />
       </Route>
 
       {/* Catch-all redirect */}

+ 106 - 0
webui/src/api/scripts.ts

@@ -0,0 +1,106 @@
+import { apiClient } from './client'
+import type {
+  Script,
+  ScriptExecution,
+  CreateScriptRequest,
+  UpdateScriptRequest,
+  ExecuteScriptRequest,
+  ScriptListResponse,
+  ExecutionListResponse,
+} from '../types/scripts'
+
+// Scripts API
+
+export const scriptsApi = {
+  // Create a new script
+  create: async (workspaceId: string, data: CreateScriptRequest): Promise<Script> => {
+    const response = await apiClient.post<Script>(
+      `/workspaces/${workspaceId}/scripts`,
+      data
+    )
+    return response
+  },
+
+  // List all scripts in a workspace
+  list: async (workspaceId: string): Promise<ScriptListResponse> => {
+    const response = await apiClient.get<ScriptListResponse>(
+      `/workspaces/${workspaceId}/scripts`
+    )
+    return response
+  },
+
+  // Get a specific script
+  get: async (workspaceId: string, scriptId: string): Promise<Script> => {
+    const response = await apiClient.get<Script>(
+      `/workspaces/${workspaceId}/scripts/${scriptId}`
+    )
+    return response
+  },
+
+  // Update a script
+  update: async (
+    workspaceId: string,
+    scriptId: string,
+    data: UpdateScriptRequest
+  ): Promise<Script> => {
+    const response = await apiClient.patch<Script>(
+      `/workspaces/${workspaceId}/scripts/${scriptId}`,
+      data
+    )
+    return response
+  },
+
+  // Delete a script
+  delete: async (
+    workspaceId: string,
+    scriptId: string
+  ): Promise<{ success: boolean; message: string }> => {
+    const response = await apiClient.delete<{ success: boolean; message: string }>(
+      `/workspaces/${workspaceId}/scripts/${scriptId}`
+    )
+    return response
+  },
+
+  // Execute a script
+  execute: async (
+    workspaceId: string,
+    scriptId: string,
+    data?: ExecuteScriptRequest
+  ): Promise<ScriptExecution> => {
+    const response = await apiClient.post<ScriptExecution>(
+      `/workspaces/${workspaceId}/scripts/${scriptId}/execute`,
+      data || {}
+    )
+    return response
+  },
+
+  // List executions for a script
+  listExecutions: async (
+    workspaceId: string,
+    scriptId: string,
+    limit?: number,
+    offset?: number
+  ): Promise<ExecutionListResponse> => {
+    const params = new URLSearchParams()
+    if (limit) params.set('limit', limit.toString())
+    if (offset) params.set('offset', offset.toString())
+    const query = params.toString() ? `?${params.toString()}` : ''
+    const response = await apiClient.get<ExecutionListResponse>(
+      `/workspaces/${workspaceId}/scripts/${scriptId}/executions${query}`
+    )
+    return response
+  },
+
+  // Get a specific execution
+  getExecution: async (
+    workspaceId: string,
+    executionId: string
+  ): Promise<ScriptExecution> => {
+    const response = await apiClient.get<ScriptExecution>(
+      `/workspaces/${workspaceId}/executions/${executionId}`
+    )
+    return response
+  },
+}
+
+export default scriptsApi

+ 2 - 1
webui/src/components/PermissionEditor.tsx

@@ -104,7 +104,8 @@ export function PermissionEditor({
       workspace: 'bg-blue-100 text-blue-800 border-blue-200',
       collection: 'bg-green-100 text-green-800 border-green-200',
       field: 'bg-yellow-100 text-yellow-800 border-yellow-200',
-      page: 'bg-orange-100 text-orange-800 border-orange-200'
+      page: 'bg-orange-100 text-orange-800 border-orange-200',
+      script: 'bg-cyan-100 text-cyan-800 border-cyan-200'
     }
 
     const colorClass = parsed ? scopeColors[parsed.scope] : 'bg-gray-100 text-gray-800 border-gray-200'

+ 11 - 0
webui/src/components/PermissionSummary.tsx

@@ -17,6 +17,7 @@ interface PermissionsByCategory {
   collection: string[]
   field: string[]
   page: string[]
+  script: string[]
   other: string[]
 }
 
@@ -26,6 +27,7 @@ const scopeLabels: Record<PermissionScope | 'other', string> = {
   collection: 'Collection',
   field: 'Field',
   page: 'Page',
+  script: 'Script',
   other: 'Other',
 }
 
@@ -35,6 +37,7 @@ const scopeColors: Record<PermissionScope | 'other', { bg: string; text: string;
   collection: { bg: 'bg-green-50', text: 'text-green-800', border: 'border-green-200' },
   field: { bg: 'bg-yellow-50', text: 'text-yellow-800', border: 'border-yellow-200' },
   page: { bg: 'bg-orange-50', text: 'text-orange-800', border: 'border-orange-200' },
+  script: { bg: 'bg-cyan-50', text: 'text-cyan-800', border: 'border-cyan-200' },
   other: { bg: 'bg-gray-50', text: 'text-gray-800', border: 'border-gray-200' },
 }
 
@@ -65,6 +68,11 @@ const scopeIcons: Record<PermissionScope | 'other', React.ReactNode> = {
       <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
     </svg>
   ),
+  script: (
+    <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
+    </svg>
+  ),
   other: (
     <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
       <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
@@ -87,6 +95,8 @@ function getPermissionDescription(permission: string): string {
       return `${action.replace('_', ' ')} on ${resource === '*' ? 'all collections' : resource}`
     case 'page':
       return `${action.replace('_', ' ')} pages`
+    case 'script':
+      return `${action.replace('_', ' ')} scripts`
     case 'field':
       return `${action} field ${parsed.qualifier || '*'} in ${resource}`
     default:
@@ -102,6 +112,7 @@ export function PermissionSummary({ permissions, compact = false }: PermissionSu
       collection: [],
       field: [],
       page: [],
+      script: [],
       other: [],
     }
 

+ 195 - 0
webui/src/components/ScriptEditor/ConfigPanel.tsx

@@ -0,0 +1,195 @@
+// Script configuration panel - metadata, settings, and permissions
+
+import Input from '@/components/Input'
+import type { ScriptConfig, ScriptStatus } from '@/types/scripts'
+import { STATUS_LABELS } from '@/types/scripts'
+
+interface ConfigPanelProps {
+  name: string
+  description: string
+  status: ScriptStatus
+  config: ScriptConfig
+  onNameChange: (value: string) => void
+  onDescriptionChange: (value: string) => void
+  onStatusChange: (value: ScriptStatus) => void
+  onConfigChange: (value: ScriptConfig) => void
+  disabled?: boolean
+  isNew?: boolean
+}
+
+function ConfigPanel({
+  name,
+  description,
+  status,
+  config,
+  onNameChange,
+  onDescriptionChange,
+  onStatusChange,
+  onConfigChange,
+  disabled = false,
+  isNew = false,
+}: ConfigPanelProps) {
+  const handleConfigField = <K extends keyof ScriptConfig>(
+    field: K,
+    value: ScriptConfig[K]
+  ) => {
+    onConfigChange({ ...config, [field]: value })
+  }
+
+  return (
+    <div className="space-y-6">
+      {/* Basic Info */}
+      <div>
+        <h3 className="mb-3 text-sm font-semibold text-gray-900">Basic Info</h3>
+        <div className="space-y-3">
+          <Input
+            label="Name"
+            value={name}
+            onChange={(e) => onNameChange(e.target.value)}
+            placeholder="My Script"
+            disabled={disabled}
+            required
+          />
+
+          <div>
+            <label className="mb-1.5 block text-sm font-medium text-gray-700">
+              Description
+            </label>
+            <textarea
+              value={description}
+              onChange={(e) => onDescriptionChange(e.target.value)}
+              placeholder="What does this script do?"
+              rows={3}
+              disabled={disabled}
+              className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm placeholder:text-gray-400 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 disabled:bg-gray-100 disabled:text-gray-500"
+            />
+          </div>
+
+          {!isNew && (
+            <div>
+              <label className="mb-1.5 block text-sm font-medium text-gray-700">
+                Status
+              </label>
+              <select
+                value={status}
+                onChange={(e) => onStatusChange(e.target.value as ScriptStatus)}
+                disabled={disabled}
+                className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 disabled:bg-gray-100 disabled:text-gray-500"
+              >
+                {Object.entries(STATUS_LABELS).map(([value, label]) => (
+                  <option key={value} value={value}>
+                    {label}
+                  </option>
+                ))}
+              </select>
+            </div>
+          )}
+        </div>
+      </div>
+
+      {/* Execution Settings */}
+      <div>
+        <h3 className="mb-3 text-sm font-semibold text-gray-900">Execution Settings</h3>
+        <div className="space-y-3">
+          <div>
+            <label className="mb-1.5 block text-sm font-medium text-gray-700">
+              Timeout (ms)
+            </label>
+            <input
+              type="range"
+              min={1000}
+              max={30000}
+              step={1000}
+              value={config.timeout_ms}
+              onChange={(e) => handleConfigField('timeout_ms', parseInt(e.target.value))}
+              disabled={disabled}
+              className="w-full"
+            />
+            <div className="flex justify-between text-xs text-gray-500">
+              <span>1s</span>
+              <span className="font-medium">{config.timeout_ms / 1000}s</span>
+              <span>30s</span>
+            </div>
+          </div>
+
+          <div>
+            <label className="mb-1.5 block text-sm font-medium text-gray-700">
+              Max Memory (MB)
+            </label>
+            <input
+              type="range"
+              min={8}
+              max={64}
+              step={8}
+              value={config.max_memory_mb}
+              onChange={(e) => handleConfigField('max_memory_mb', parseInt(e.target.value))}
+              disabled={disabled}
+              className="w-full"
+            />
+            <div className="flex justify-between text-xs text-gray-500">
+              <span>8MB</span>
+              <span className="font-medium">{config.max_memory_mb}MB</span>
+              <span>64MB</span>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      {/* Permissions */}
+      <div>
+        <h3 className="mb-3 text-sm font-semibold text-gray-900">Permissions</h3>
+        <div className="space-y-2">
+          <label className="flex items-center gap-2">
+            <input
+              type="checkbox"
+              checked={config.allow_network}
+              onChange={(e) => handleConfigField('allow_network', e.target.checked)}
+              disabled={disabled}
+              className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
+            />
+            <span className="text-sm text-gray-700">Allow HTTP requests</span>
+          </label>
+
+          <label className="flex items-center gap-2">
+            <input
+              type="checkbox"
+              checked={config.allow_storage}
+              onChange={(e) => handleConfigField('allow_storage', e.target.checked)}
+              disabled={disabled}
+              className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
+            />
+            <span className="text-sm text-gray-700">Allow key-value storage</span>
+          </label>
+        </div>
+      </div>
+
+      {/* Collection Access */}
+      <div>
+        <h3 className="mb-3 text-sm font-semibold text-gray-900">Collection Access</h3>
+        <p className="mb-2 text-xs text-gray-500">
+          Collections this script can access. Leave empty to allow all (subject to creator's permissions).
+        </p>
+        <div>
+          <input
+            type="text"
+            value={config.allowed_collections.join(', ')}
+            onChange={(e) =>
+              handleConfigField(
+                'allowed_collections',
+                e.target.value
+                  .split(',')
+                  .map((s) => s.trim())
+                  .filter(Boolean)
+              )
+            }
+            placeholder="e.g., products, orders"
+            disabled={disabled}
+            className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm placeholder:text-gray-400 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 disabled:bg-gray-100 disabled:text-gray-500"
+          />
+        </div>
+      </div>
+    </div>
+  )
+}
+
+export default ConfigPanel

+ 195 - 0
webui/src/components/ScriptEditor/CronExpressionInput.tsx

@@ -0,0 +1,195 @@
+// Cron expression input with helper presets and next-run preview
+
+import { useState, useMemo } from 'react'
+
+interface CronExpressionInputProps {
+  value: string
+  onChange: (value: string) => void
+  disabled?: boolean
+}
+
+// Common cron presets
+const CRON_PRESETS = [
+  { label: 'Every minute', value: '* * * * *' },
+  { label: 'Every 5 minutes', value: '*/5 * * * *' },
+  { label: 'Every 15 minutes', value: '*/15 * * * *' },
+  { label: 'Every hour', value: '0 * * * *' },
+  { label: 'Every 6 hours', value: '0 */6 * * *' },
+  { label: 'Every day at midnight', value: '0 0 * * *' },
+  { label: 'Every day at 9 AM', value: '0 9 * * *' },
+  { label: 'Every Monday at 9 AM', value: '0 9 * * 1' },
+  { label: 'First day of month', value: '0 0 1 * *' },
+]
+
+// Parse cron expression and generate human-readable description
+function describeCron(expression: string): string {
+  const parts = expression.trim().split(/\s+/)
+  if (parts.length !== 5) return 'Invalid expression'
+
+  const [minute, hour, dayOfMonth, month, dayOfWeek] = parts
+
+  // Simple descriptions for common patterns
+  if (expression === '* * * * *') return 'Every minute'
+  if (expression === '0 * * * *') return 'Every hour at minute 0'
+  if (expression === '0 0 * * *') return 'Every day at midnight'
+
+  if (minute.startsWith('*/')) {
+    const interval = minute.substring(2)
+    if (hour === '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
+      return `Every ${interval} minutes`
+    }
+  }
+
+  if (hour.startsWith('*/')) {
+    const interval = hour.substring(2)
+    if (minute === '0' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
+      return `Every ${interval} hours`
+    }
+  }
+
+  if (dayOfWeek !== '*' && dayOfMonth === '*') {
+    const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
+    const dayNum = parseInt(dayOfWeek)
+    if (!isNaN(dayNum) && dayNum >= 0 && dayNum <= 6) {
+      return `Every ${days[dayNum]} at ${hour}:${minute.padStart(2, '0')}`
+    }
+  }
+
+  if (dayOfMonth !== '*' && dayOfWeek === '*') {
+    return `Day ${dayOfMonth} of every month at ${hour}:${minute.padStart(2, '0')}`
+  }
+
+  // Generic description
+  return `${minute} ${hour} ${dayOfMonth} ${month} ${dayOfWeek}`
+}
+
+// Calculate next run time (simplified)
+function getNextRuns(expression: string, count: number = 3): Date[] {
+  const parts = expression.trim().split(/\s+/)
+  if (parts.length !== 5) return []
+
+  const [minutePart, hourPart] = parts
+  const runs: Date[] = []
+  const now = new Date()
+
+  // Simple implementation for common cases
+  for (let i = 0; i < 60 * 24 && runs.length < count; i++) {
+    const candidate = new Date(now.getTime() + i * 60 * 1000)
+
+    // Check minute
+    if (minutePart !== '*') {
+      if (minutePart.startsWith('*/')) {
+        const interval = parseInt(minutePart.substring(2))
+        if (candidate.getMinutes() % interval !== 0) continue
+      } else if (candidate.getMinutes() !== parseInt(minutePart)) {
+        continue
+      }
+    }
+
+    // Check hour
+    if (hourPart !== '*') {
+      if (hourPart.startsWith('*/')) {
+        const interval = parseInt(hourPart.substring(2))
+        if (candidate.getHours() % interval !== 0) continue
+      } else if (candidate.getHours() !== parseInt(hourPart)) {
+        continue
+      }
+    }
+
+    if (candidate > now) {
+      runs.push(candidate)
+    }
+  }
+
+  return runs
+}
+
+function CronExpressionInput({ value, onChange, disabled = false }: CronExpressionInputProps) {
+  const [showPresets, setShowPresets] = useState(false)
+
+  const description = useMemo(() => describeCron(value), [value])
+  const nextRuns = useMemo(() => getNextRuns(value), [value])
+
+  const isValid = value.trim().split(/\s+/).length === 5
+
+  return (
+    <div className="space-y-2">
+      <div>
+        <label className="mb-1 block text-xs font-medium text-gray-600">
+          Cron Expression
+        </label>
+        <div className="flex gap-2">
+          <input
+            type="text"
+            value={value}
+            onChange={(e) => onChange(e.target.value)}
+            placeholder="* * * * *"
+            disabled={disabled}
+            className={`flex-1 rounded border px-2 py-1.5 font-mono text-sm placeholder:text-gray-400 focus:outline-none disabled:bg-gray-100 ${
+              isValid
+                ? 'border-gray-300 focus:border-primary-500'
+                : 'border-red-300 focus:border-red-500'
+            }`}
+          />
+          {!disabled && (
+            <button
+              onClick={() => setShowPresets(!showPresets)}
+              className="rounded border border-gray-300 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50"
+            >
+              Presets
+            </button>
+          )}
+        </div>
+      </div>
+
+      {/* Presets dropdown */}
+      {showPresets && !disabled && (
+        <div className="rounded border border-gray-200 bg-white p-2 shadow-sm">
+          <div className="max-h-48 space-y-1 overflow-y-auto">
+            {CRON_PRESETS.map((preset) => (
+              <button
+                key={preset.value}
+                onClick={() => {
+                  onChange(preset.value)
+                  setShowPresets(false)
+                }}
+                className="flex w-full items-center justify-between rounded px-2 py-1.5 text-left text-xs hover:bg-gray-50"
+              >
+                <span className="text-gray-700">{preset.label}</span>
+                <span className="font-mono text-gray-400">{preset.value}</span>
+              </button>
+            ))}
+          </div>
+        </div>
+      )}
+
+      {/* Description */}
+      {isValid && (
+        <p className="text-xs text-gray-600">
+          <span className="font-medium">Schedule:</span> {description}
+        </p>
+      )}
+
+      {/* Next runs preview */}
+      {isValid && nextRuns.length > 0 && (
+        <div className="rounded bg-gray-50 p-2">
+          <p className="mb-1 text-xs font-medium text-gray-600">Next runs:</p>
+          <ul className="space-y-0.5">
+            {nextRuns.map((run, i) => (
+              <li key={i} className="text-xs text-gray-500">
+                {run.toLocaleString()}
+              </li>
+            ))}
+          </ul>
+        </div>
+      )}
+
+      {/* Help text */}
+      <p className="text-xs text-gray-400">
+        Format: minute hour day month weekday (0-6, Sun=0)
+      </p>
+    </div>
+  )
+}
+
+export default CronExpressionInput

+ 312 - 0
webui/src/components/ScriptEditor/ExecutionPanel.tsx

@@ -0,0 +1,312 @@
+// Execution panel - test execution, logs, and execution history
+
+import { useState, useEffect } from 'react'
+import scriptsApi from '@/api/scripts'
+import Button from '@/components/Button'
+import type { ScriptExecution, ExecutionStatus } from '@/types/scripts'
+import { EXECUTION_STATUS_LABELS, EXECUTION_STATUS_COLORS } from '@/types/scripts'
+
+interface ExecutionPanelProps {
+  execution: ScriptExecution | null
+  isExecuting: boolean
+  onExecute: () => void
+  canExecute: boolean
+  scriptId?: string
+  workspaceId: string
+}
+
+// Status badge
+function ExecutionStatusBadge({ status }: { status: ExecutionStatus }) {
+  return (
+    <span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${EXECUTION_STATUS_COLORS[status]}`}>
+      {EXECUTION_STATUS_LABELS[status]}
+    </span>
+  )
+}
+
+// Format duration
+function formatDuration(ms: number): string {
+  if (ms < 1000) return `${ms}ms`
+  if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
+  return `${(ms / 60000).toFixed(1)}m`
+}
+
+// Format timestamp
+function formatTime(dateStr: string): string {
+  return new Date(dateStr).toLocaleTimeString()
+}
+
+// Log line component
+function LogLine({ log, index }: { log: string; index: number }) {
+  // Parse log level from message
+  let level = 'info'
+  let message = log
+
+  if (log.startsWith('[DEBUG]')) {
+    level = 'debug'
+    message = log.substring(7).trim()
+  } else if (log.startsWith('[INFO]')) {
+    level = 'info'
+    message = log.substring(6).trim()
+  } else if (log.startsWith('[WARN]')) {
+    level = 'warn'
+    message = log.substring(6).trim()
+  } else if (log.startsWith('[ERROR]')) {
+    level = 'error'
+    message = log.substring(7).trim()
+  }
+
+  const levelColors = {
+    debug: 'text-gray-400',
+    info: 'text-blue-600',
+    warn: 'text-yellow-600',
+    error: 'text-red-600',
+  }
+
+  return (
+    <div className="flex gap-2 font-mono text-xs">
+      <span className="text-gray-400">{String(index + 1).padStart(2, '0')}</span>
+      <span className={levelColors[level as keyof typeof levelColors] || 'text-gray-700'}>
+        {message}
+      </span>
+    </div>
+  )
+}
+
+// Execution details
+function ExecutionDetails({ execution }: { execution: ScriptExecution }) {
+  return (
+    <div className="space-y-4">
+      {/* Header */}
+      <div className="flex items-center justify-between">
+        <ExecutionStatusBadge status={execution.status} />
+        <span className="text-xs text-gray-500">
+          {formatDuration(execution.execution_time_ms)}
+        </span>
+      </div>
+
+      {/* Timestamps */}
+      <div className="space-y-1 text-xs text-gray-500">
+        <div className="flex justify-between">
+          <span>Started:</span>
+          <span>{formatTime(execution.started_at)}</span>
+        </div>
+        {execution.completed_at && (
+          <div className="flex justify-between">
+            <span>Completed:</span>
+            <span>{formatTime(execution.completed_at)}</span>
+          </div>
+        )}
+      </div>
+
+      {/* Error */}
+      {execution.error && (
+        <div className="rounded bg-red-50 p-2">
+          <p className="text-xs font-medium text-red-800">Error:</p>
+          <p className="mt-1 font-mono text-xs text-red-700">{execution.error}</p>
+        </div>
+      )}
+
+      {/* Result */}
+      {execution.result && (
+        <div className="rounded bg-green-50 p-2">
+          <p className="text-xs font-medium text-green-800">Result:</p>
+          <pre className="mt-1 overflow-x-auto whitespace-pre-wrap font-mono text-xs text-green-700">
+            {execution.result}
+          </pre>
+        </div>
+      )}
+
+      {/* Logs */}
+      <div>
+        <p className="mb-2 text-xs font-medium text-gray-700">
+          Console Output ({execution.logs.length} lines)
+        </p>
+        <div className="max-h-64 overflow-y-auto rounded border border-gray-200 bg-gray-900 p-2">
+          {execution.logs.length === 0 ? (
+            <p className="text-xs text-gray-500">No console output</p>
+          ) : (
+            <div className="space-y-0.5">
+              {execution.logs.map((log, i) => (
+                <LogLine key={i} log={log} index={i} />
+              ))}
+            </div>
+          )}
+        </div>
+      </div>
+    </div>
+  )
+}
+
+// Execution history list
+function ExecutionHistory({
+  scriptId,
+  workspaceId,
+  onSelect,
+}: {
+  scriptId: string
+  workspaceId: string
+  onSelect: (execution: ScriptExecution) => void
+}) {
+  const [executions, setExecutions] = useState<ScriptExecution[]>([])
+  const [isLoading, setIsLoading] = useState(true)
+
+  useEffect(() => {
+    const loadExecutions = async () => {
+      try {
+        const response = await scriptsApi.listExecutions(workspaceId, scriptId, 10)
+        // The API returns ScriptExecutionListItem but we need full ScriptExecution for display
+        // For now we'll just show the list items
+        setExecutions(response.executions as unknown as ScriptExecution[])
+      } catch {
+        // Ignore errors
+      } finally {
+        setIsLoading(false)
+      }
+    }
+
+    loadExecutions()
+  }, [scriptId, workspaceId])
+
+  if (isLoading) {
+    return <p className="text-center text-xs text-gray-500 py-2">Loading...</p>
+  }
+
+  if (executions.length === 0) {
+    return <p className="text-center text-xs text-gray-500 py-2">No execution history</p>
+  }
+
+  return (
+    <div className="space-y-1">
+      {executions.map((exec) => (
+        <button
+          key={exec.id}
+          onClick={() => onSelect(exec)}
+          className="flex w-full items-center justify-between rounded px-2 py-1.5 text-left text-xs hover:bg-gray-100"
+        >
+          <div className="flex items-center gap-2">
+            <ExecutionStatusBadge status={exec.status} />
+            <span className="text-gray-500">{exec.trigger_type}</span>
+          </div>
+          <span className="text-gray-400">{formatTime(exec.started_at)}</span>
+        </button>
+      ))}
+    </div>
+  )
+}
+
+function ExecutionPanel({
+  execution,
+  isExecuting,
+  onExecute,
+  canExecute,
+  scriptId,
+  workspaceId,
+}: ExecutionPanelProps) {
+  const [activeTab, setActiveTab] = useState<'current' | 'history'>('current')
+  const [selectedExecution, setSelectedExecution] = useState<ScriptExecution | null>(null)
+
+  // When new execution starts, switch to current tab
+  useEffect(() => {
+    if (execution) {
+      setActiveTab('current')
+      setSelectedExecution(null)
+    }
+  }, [execution])
+
+  const displayedExecution = selectedExecution || execution
+
+  return (
+    <div className="space-y-4">
+      <div className="flex items-center justify-between">
+        <h3 className="text-sm font-semibold text-gray-900">Execution</h3>
+      </div>
+
+      {/* Execute button */}
+      {canExecute && (
+        <Button
+          onClick={onExecute}
+          disabled={isExecuting}
+          isLoading={isExecuting}
+          className="w-full"
+        >
+          {isExecuting ? 'Running...' : 'Run Script'}
+        </Button>
+      )}
+
+      {/* Tabs */}
+      {scriptId && (
+        <div className="flex border-b border-gray-200">
+          <button
+            onClick={() => {
+              setActiveTab('current')
+              setSelectedExecution(null)
+            }}
+            className={`flex-1 border-b-2 px-3 py-2 text-xs font-medium ${
+              activeTab === 'current'
+                ? 'border-primary-500 text-primary-600'
+                : 'border-transparent text-gray-500 hover:text-gray-700'
+            }`}
+          >
+            Current
+          </button>
+          <button
+            onClick={() => setActiveTab('history')}
+            className={`flex-1 border-b-2 px-3 py-2 text-xs font-medium ${
+              activeTab === 'history'
+                ? 'border-primary-500 text-primary-600'
+                : 'border-transparent text-gray-500 hover:text-gray-700'
+            }`}
+          >
+            History
+          </button>
+        </div>
+      )}
+
+      {/* Content */}
+      {activeTab === 'current' ? (
+        displayedExecution ? (
+          <ExecutionDetails execution={displayedExecution} />
+        ) : (
+          <div className="rounded-lg border-2 border-dashed border-gray-200 p-6 text-center">
+            <svg
+              className="mx-auto h-8 w-8 text-gray-400"
+              fill="none"
+              viewBox="0 0 24 24"
+              stroke="currentColor"
+            >
+              <path
+                strokeLinecap="round"
+                strokeLinejoin="round"
+                strokeWidth={1.5}
+                d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
+              />
+              <path
+                strokeLinecap="round"
+                strokeLinejoin="round"
+                strokeWidth={1.5}
+                d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
+              />
+            </svg>
+            <p className="mt-2 text-xs text-gray-500">
+              {canExecute
+                ? 'Click "Run Script" to test your automation'
+                : 'Save and activate the script to test it'}
+            </p>
+          </div>
+        )
+      ) : scriptId ? (
+        <ExecutionHistory
+          scriptId={scriptId}
+          workspaceId={workspaceId}
+          onSelect={(exec) => {
+            setSelectedExecution(exec)
+            setActiveTab('current')
+          }}
+        />
+      ) : null}
+    </div>
+  )
+}
+
+export default ExecutionPanel

+ 251 - 0
webui/src/components/ScriptEditor/TriggerEditor.tsx

@@ -0,0 +1,251 @@
+// Trigger editor - configure event and cron triggers for scripts
+
+import { useState } from 'react'
+import CronExpressionInput from './CronExpressionInput'
+import type { ScriptTrigger, TriggerType } from '@/types/scripts'
+import { ENTITY_TYPES, EVENT_ACTIONS } from '@/types/scripts'
+
+interface TriggerEditorProps {
+  triggers: ScriptTrigger[]
+  onChange: (triggers: ScriptTrigger[]) => void
+  disabled?: boolean
+  workspaceId: string
+}
+
+// Generate a simple unique ID
+function generateId(): string {
+  return `t_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`
+}
+
+// Single trigger item
+function TriggerItem({
+  trigger,
+  onUpdate,
+  onDelete,
+  disabled,
+}: {
+  trigger: ScriptTrigger
+  onUpdate: (trigger: ScriptTrigger) => void
+  onDelete: () => void
+  disabled: boolean
+}) {
+  const [expanded, setExpanded] = useState(true)
+
+  return (
+    <div className="rounded-lg border border-gray-200 bg-white">
+      {/* Header */}
+      <div
+        className="flex cursor-pointer items-center justify-between px-3 py-2"
+        onClick={() => setExpanded(!expanded)}
+      >
+        <div className="flex items-center gap-2">
+          {trigger.type === 'event' ? (
+            <svg className="h-4 w-4 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
+            </svg>
+          ) : (
+            <svg className="h-4 w-4 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
+            </svg>
+          )}
+          <span className="text-sm font-medium text-gray-700">
+            {trigger.type === 'event'
+              ? `On ${trigger.event_action || 'event'} ${trigger.entity_type || 'entity'}`
+              : `Cron: ${trigger.cron_expression || '* * * * *'}`}
+          </span>
+        </div>
+        <div className="flex items-center gap-1">
+          {!disabled && (
+            <button
+              onClick={(e) => {
+                e.stopPropagation()
+                onDelete()
+              }}
+              className="rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-600"
+            >
+              <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
+              </svg>
+            </button>
+          )}
+          <svg
+            className={`h-4 w-4 text-gray-400 transition-transform ${expanded ? 'rotate-180' : ''}`}
+            fill="none"
+            viewBox="0 0 24 24"
+            stroke="currentColor"
+          >
+            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
+          </svg>
+        </div>
+      </div>
+
+      {/* Content */}
+      {expanded && (
+        <div className="border-t px-3 py-3 space-y-3">
+          {/* Trigger Type */}
+          <div>
+            <label className="mb-1 block text-xs font-medium text-gray-600">Type</label>
+            <select
+              value={trigger.type}
+              onChange={(e) => onUpdate({ ...trigger, type: e.target.value as TriggerType })}
+              disabled={disabled}
+              className="w-full rounded border border-gray-300 px-2 py-1.5 text-sm focus:border-primary-500 focus:outline-none disabled:bg-gray-100"
+            >
+              <option value="event">Event</option>
+              <option value="cron">Cron Schedule</option>
+            </select>
+          </div>
+
+          {trigger.type === 'event' ? (
+            <>
+              {/* Entity Type */}
+              <div>
+                <label className="mb-1 block text-xs font-medium text-gray-600">Entity Type</label>
+                <select
+                  value={trigger.entity_type || ''}
+                  onChange={(e) => onUpdate({ ...trigger, entity_type: e.target.value })}
+                  disabled={disabled}
+                  className="w-full rounded border border-gray-300 px-2 py-1.5 text-sm focus:border-primary-500 focus:outline-none disabled:bg-gray-100"
+                >
+                  <option value="">Any</option>
+                  {ENTITY_TYPES.map((type) => (
+                    <option key={type} value={type}>
+                      {type.charAt(0).toUpperCase() + type.slice(1)}
+                    </option>
+                  ))}
+                </select>
+              </div>
+
+              {/* Event Action */}
+              <div>
+                <label className="mb-1 block text-xs font-medium text-gray-600">Action</label>
+                <select
+                  value={trigger.event_action || ''}
+                  onChange={(e) => onUpdate({ ...trigger, event_action: e.target.value })}
+                  disabled={disabled}
+                  className="w-full rounded border border-gray-300 px-2 py-1.5 text-sm focus:border-primary-500 focus:outline-none disabled:bg-gray-100"
+                >
+                  <option value="">Any</option>
+                  {EVENT_ACTIONS.map((action) => (
+                    <option key={action} value={action}>
+                      {action.charAt(0).toUpperCase() + action.slice(1)}
+                    </option>
+                  ))}
+                </select>
+              </div>
+
+              {/* Collection Filter */}
+              <div>
+                <label className="mb-1 block text-xs font-medium text-gray-600">
+                  Collection Filter (optional)
+                </label>
+                <input
+                  type="text"
+                  value={trigger.collection_filter || ''}
+                  onChange={(e) => onUpdate({ ...trigger, collection_filter: e.target.value })}
+                  placeholder="e.g., orders"
+                  disabled={disabled}
+                  className="w-full rounded border border-gray-300 px-2 py-1.5 text-sm placeholder:text-gray-400 focus:border-primary-500 focus:outline-none disabled:bg-gray-100"
+                />
+              </div>
+            </>
+          ) : (
+            <>
+              {/* Cron Expression */}
+              <CronExpressionInput
+                value={trigger.cron_expression || '0 * * * *'}
+                onChange={(value: string) => onUpdate({ ...trigger, cron_expression: value })}
+                disabled={disabled}
+              />
+
+              {/* Timezone */}
+              <div>
+                <label className="mb-1 block text-xs font-medium text-gray-600">Timezone</label>
+                <select
+                  value={trigger.timezone || 'UTC'}
+                  onChange={(e) => onUpdate({ ...trigger, timezone: e.target.value })}
+                  disabled={disabled}
+                  className="w-full rounded border border-gray-300 px-2 py-1.5 text-sm focus:border-primary-500 focus:outline-none disabled:bg-gray-100"
+                >
+                  <option value="UTC">UTC</option>
+                  <option value="Europe/Budapest">Europe/Budapest</option>
+                  <option value="Europe/London">Europe/London</option>
+                  <option value="America/New_York">America/New_York</option>
+                  <option value="America/Los_Angeles">America/Los_Angeles</option>
+                  <option value="Asia/Tokyo">Asia/Tokyo</option>
+                </select>
+              </div>
+            </>
+          )}
+        </div>
+      )}
+    </div>
+  )
+}
+
+function TriggerEditor({ triggers, onChange, disabled = false }: TriggerEditorProps) {
+  const addTrigger = (type: TriggerType) => {
+    const newTrigger: ScriptTrigger = {
+      id: generateId(),
+      type,
+      ...(type === 'event'
+        ? { entity_type: 'document', event_action: 'create' }
+        : { cron_expression: '0 * * * *', timezone: 'UTC' }),
+    }
+    onChange([...triggers, newTrigger])
+  }
+
+  const updateTrigger = (id: string, updated: ScriptTrigger) => {
+    onChange(triggers.map((t) => (t.id === id ? updated : t)))
+  }
+
+  const deleteTrigger = (id: string) => {
+    onChange(triggers.filter((t) => t.id !== id))
+  }
+
+  return (
+    <div>
+      <div className="mb-3 flex items-center justify-between">
+        <h3 className="text-sm font-semibold text-gray-900">Triggers</h3>
+        {!disabled && (
+          <div className="flex gap-1">
+            <button
+              onClick={() => addTrigger('event')}
+              className="rounded px-2 py-1 text-xs font-medium text-blue-600 hover:bg-blue-50"
+              title="Add event trigger"
+            >
+              + Event
+            </button>
+            <button
+              onClick={() => addTrigger('cron')}
+              className="rounded px-2 py-1 text-xs font-medium text-purple-600 hover:bg-purple-50"
+              title="Add cron trigger"
+            >
+              + Cron
+            </button>
+          </div>
+        )}
+      </div>
+
+      {triggers.length === 0 ? (
+        <p className="text-center text-xs text-gray-500 py-4">
+          No triggers configured. Script can only be executed manually.
+        </p>
+      ) : (
+        <div className="space-y-2">
+          {triggers.map((trigger) => (
+            <TriggerItem
+              key={trigger.id}
+              trigger={trigger}
+              onUpdate={(updated) => updateTrigger(trigger.id, updated)}
+              onDelete={() => deleteTrigger(trigger.id)}
+              disabled={disabled}
+            />
+          ))}
+        </div>
+      )}
+    </div>
+  )
+}
+
+export default TriggerEditor

+ 7 - 0
webui/src/components/ScriptEditor/index.tsx

@@ -0,0 +1,7 @@
+// Script Editor components
+// Full-page editor for automation scripts with Monaco, config, triggers, and execution
+
+export { default as ConfigPanel } from './ConfigPanel'
+export { default as TriggerEditor } from './TriggerEditor'
+export { default as ExecutionPanel } from './ExecutionPanel'
+export { default as CronExpressionInput } from './CronExpressionInput'

+ 15 - 0
webui/src/components/layout/Sidebar.tsx

@@ -30,6 +30,7 @@ const menuPermissions: Record<string, string[]> = {
   '/collections': ['system:collections:read'],
   '/views': ['system:views:read'],
   '/pages': ['system:pages:read', 'page:*:read_all', 'page:*:read_own'],
+  '/scripts': ['script:*:read_all', 'script:*:read_own', 'script:*:create'],
   '/api-keys': ['system:api_keys:read'],
   '/archives': ['system:archives:read'],
 }
@@ -105,6 +106,20 @@ const navigation: NavItem[] = [
       </svg>
     ),
   },
+  {
+    name: 'Scripts',
+    path: '/scripts',
+    icon: (
+      <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+        <path
+          strokeLinecap="round"
+          strokeLinejoin="round"
+          strokeWidth={2}
+          d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"
+        />
+      </svg>
+    ),
+  },
   {
     name: 'Users',
     path: '/users',

+ 81 - 1
webui/src/hooks/usePermissions.ts

@@ -3,7 +3,7 @@
 import { useState, useEffect, useCallback, useMemo } from 'react'
 import apiClient from '@/api/client'
 import { useAuth } from '@/contexts/AuthContext'
-import { matchesPermission, buildPagePermission, buildCollectionPermission, buildSystemPermission, SystemResource, SystemAction } from '@/types'
+import { matchesPermission, buildPagePermission, buildCollectionPermission, buildSystemPermission, buildScriptPermission, SystemResource, SystemAction } from '@/types'
 
 interface Group {
   id: string
@@ -41,6 +41,11 @@ interface UsePermissionsResult {
   canEditPage: (pageOwnerId: string) => boolean
   canDeletePage: (pageOwnerId: string) => boolean
   canSharePage: (pageOwnerId: string) => boolean
+  canCreateScript: () => boolean
+  canViewScript: (scriptOwnerId: string) => boolean
+  canEditScript: (scriptOwnerId: string) => boolean
+  canDeleteScript: (scriptOwnerId: string) => boolean
+  canExecuteScript: () => boolean
   refetch: () => Promise<void>
 }
 
@@ -275,6 +280,76 @@ export function usePermissions({
     [workspaceId, user, hasPermission]
   )
 
+  // Script permission helpers
+  const canCreateScript = useCallback((): boolean => {
+    if (!workspaceId) return false
+    const create = buildScriptPermission(workspaceId, 'create')
+    return hasPermission(create)
+  }, [workspaceId, hasPermission])
+
+  const canViewScript = useCallback(
+    (scriptOwnerId: string): boolean => {
+      if (!workspaceId || !user) return false
+
+      // Check read_all permission
+      const readAll = buildScriptPermission(workspaceId, 'read_all')
+      if (hasPermission(readAll)) return true
+
+      // Check if user owns the script
+      if (scriptOwnerId === user.id) {
+        const readOwn = buildScriptPermission(workspaceId, 'read_own')
+        return hasPermission(readOwn)
+      }
+
+      return false
+    },
+    [workspaceId, user, hasPermission]
+  )
+
+  const canEditScript = useCallback(
+    (scriptOwnerId: string): boolean => {
+      if (!workspaceId || !user) return false
+
+      // Check write_all permission
+      const writeAll = buildScriptPermission(workspaceId, 'write_all')
+      if (hasPermission(writeAll)) return true
+
+      // Check if user owns the script and has write_own
+      if (scriptOwnerId === user.id) {
+        const writeOwn = buildScriptPermission(workspaceId, 'write_own')
+        return hasPermission(writeOwn)
+      }
+
+      return false
+    },
+    [workspaceId, user, hasPermission]
+  )
+
+  const canDeleteScript = useCallback(
+    (scriptOwnerId: string): boolean => {
+      if (!workspaceId || !user) return false
+
+      // Check delete_all permission
+      const deleteAll = buildScriptPermission(workspaceId, 'delete_all')
+      if (hasPermission(deleteAll)) return true
+
+      // Check if user owns the script and has delete_own
+      if (scriptOwnerId === user.id) {
+        const deleteOwn = buildScriptPermission(workspaceId, 'delete_own')
+        return hasPermission(deleteOwn)
+      }
+
+      return false
+    },
+    [workspaceId, user, hasPermission]
+  )
+
+  const canExecuteScript = useCallback((): boolean => {
+    if (!workspaceId) return false
+    const execute = buildScriptPermission(workspaceId, 'execute')
+    return hasPermission(execute)
+  }, [workspaceId, hasPermission])
+
   return {
     permissions,
     isLoading,
@@ -294,6 +369,11 @@ export function usePermissions({
     canEditPage,
     canDeletePage,
     canSharePage,
+    canCreateScript,
+    canViewScript,
+    canEditScript,
+    canDeleteScript,
+    canExecuteScript,
     refetch: fetchPermissions,
   }
 }

+ 493 - 0
webui/src/pages/ScriptEdit.tsx

@@ -0,0 +1,493 @@
+// Script Editor page - full-page editor with Monaco, config panel, and test execution
+// Layout: Left sidebar (config) | Center (Monaco editor) | Right panel (execution logs)
+
+import { useState, useEffect, useCallback, useRef } from 'react'
+import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
+import Editor from '@monaco-editor/react'
+import { useWorkspace } from '@/contexts/WorkspaceContext'
+import { usePermissions } from '@/hooks/usePermissions'
+import scriptsApi from '@/api/scripts'
+import Button from '@/components/Button'
+import ConfigPanel from '@/components/ScriptEditor/ConfigPanel'
+import TriggerEditor from '@/components/ScriptEditor/TriggerEditor'
+import ExecutionPanel from '@/components/ScriptEditor/ExecutionPanel'
+import type {
+  ScriptConfig,
+  ScriptTrigger,
+  ScriptStatus,
+  ScriptExecution,
+} from '@/types/scripts'
+import { DEFAULT_SCRIPT_CONFIG } from '@/types/scripts'
+
+// Default script code template
+const DEFAULT_SCRIPT_CODE = `// SmartBotic Automation Script
+// Available APIs:
+// - smartbotic.log.info(message), .warn(), .error(), .debug()
+// - smartbotic.storage.get(key), .set(key, value), .del(key)
+// - smartbotic.utils.uuid(), .timestamp(), .formatDate(ts, fmt)
+// - db.get(collection, id), db.query(collection, filter), db.count(collection)
+// - db.create(collection, data), db.update(collection, id, data), db.delete(collection, id)
+// - ctx.user, ctx.workspace (execution context)
+// - event (trigger event data, for event-triggered scripts)
+
+async function main() {
+  smartbotic.log.info('Script started');
+
+  // Your automation logic here
+
+  smartbotic.log.info('Script completed');
+  return { success: true };
+}
+
+main();
+`
+
+// TypeScript definitions for script context
+const SCRIPT_TYPE_DEFS = `
+declare namespace smartbotic {
+  namespace log {
+    function debug(message: string): void;
+    function info(message: string): void;
+    function warn(message: string): void;
+    function error(message: string): void;
+  }
+  namespace storage {
+    function get(key: string): string | null;
+    function set(key: string, value: string, ttlSeconds?: number): void;
+    function del(key: string): void;
+    function exists(key: string): boolean;
+    function list(prefix?: string): string[];
+    function count(prefix?: string): number;
+  }
+  namespace http {
+    interface RequestOptions {
+      headers?: Record<string, string>;
+      timeout?: number;
+    }
+    interface Response {
+      status: number;
+      headers: Record<string, string>;
+      body: string;
+      json(): any;
+    }
+    function get(url: string, options?: RequestOptions): Promise<Response>;
+    function post(url: string, body: any, options?: RequestOptions): Promise<Response>;
+    function put(url: string, body: any, options?: RequestOptions): Promise<Response>;
+    function del(url: string, options?: RequestOptions): Promise<Response>;
+  }
+  namespace utils {
+    function uuid(): string;
+    function sleep(ms: number): Promise<void>;
+    function base64Encode(data: string): string;
+    function base64Decode(data: string): string;
+    function hashSha256(data: string): string;
+    function timestamp(): number;
+    function formatDate(timestamp: number, format: string): string;
+  }
+}
+
+declare namespace db {
+  function get(collection: string, id: string): any | null;
+  function query(collection: string, filter?: Record<string, any>, options?: { limit?: number; offset?: number }): any[];
+  function count(collection: string, filter?: Record<string, any>): number;
+  function create(collection: string, data: Record<string, any>): any;
+  function update(collection: string, id: string, data: Record<string, any>): any;
+  function delete(collection: string, id: string): void;
+}
+
+declare const ctx: {
+  user: { id: string; email: string; name: string };
+  workspace: { id: string; name: string };
+};
+
+declare const event: {
+  type: string;
+  action: string;
+  entity_type: string;
+  entity_id: string;
+  data: Record<string, any>;
+  timestamp: string;
+};
+`
+
+function ScriptEdit() {
+  const navigate = useNavigate()
+  const { id } = useParams<{ id: string }>()
+  const [searchParams] = useSearchParams()
+  const { currentWorkspace } = useWorkspace()
+  const { canCreateScript, canEditScript, canExecuteScript } = usePermissions({
+    workspaceId: currentWorkspace?.id,
+  })
+
+  const isNew = !id || id === 'new'
+  const openTest = searchParams.get('test') === 'true'
+
+  // Form state
+  const [name, setName] = useState('')
+  const [description, setDescription] = useState('')
+  const [code, setCode] = useState(DEFAULT_SCRIPT_CODE)
+  const [status, setStatus] = useState<ScriptStatus>('draft')
+  const [config, setConfig] = useState<ScriptConfig>({ ...DEFAULT_SCRIPT_CONFIG })
+  const [triggers, setTriggers] = useState<ScriptTrigger[]>([])
+  const [createdBy, setCreatedBy] = useState<string>('')
+
+  // UI state
+  const [isLoading, setIsLoading] = useState(!isNew)
+  const [isSaving, setIsSaving] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+  const [hasChanges, setHasChanges] = useState(false)
+  const [showRightPanel, setShowRightPanel] = useState(openTest)
+
+  // Execution state
+  const [lastExecution, setLastExecution] = useState<ScriptExecution | null>(null)
+  const [isExecuting, setIsExecuting] = useState(false)
+
+  // Ref for Monaco editor
+  const editorRef = useRef<any>(null)
+
+  // Load script if editing
+  useEffect(() => {
+    if (isNew || !currentWorkspace || !id) {
+      setIsLoading(false)
+      return
+    }
+
+    const loadScript = async () => {
+      setIsLoading(true)
+      setError(null)
+
+      try {
+        const script = await scriptsApi.get(currentWorkspace.id, id)
+        setName(script.name)
+        setDescription(script.description)
+        setCode(script.code || DEFAULT_SCRIPT_CODE)
+        setStatus(script.status)
+        setConfig(script.config || { ...DEFAULT_SCRIPT_CONFIG })
+        setTriggers(script.triggers || [])
+        setCreatedBy(script.created_by)
+      } catch (err) {
+        setError(err instanceof Error ? err.message : 'Failed to load script')
+      } finally {
+        setIsLoading(false)
+      }
+    }
+
+    loadScript()
+  }, [isNew, currentWorkspace, id])
+
+  // Auto-save draft to localStorage
+  useEffect(() => {
+    if (!currentWorkspace) return
+
+    const draftKey = `script_draft_${currentWorkspace.id}_${id || 'new'}`
+
+    // Load draft on mount
+    const savedDraft = localStorage.getItem(draftKey)
+    if (savedDraft && isNew) {
+      try {
+        const draft = JSON.parse(savedDraft)
+        if (draft.code) setCode(draft.code)
+        if (draft.name) setName(draft.name)
+        if (draft.description) setDescription(draft.description)
+      } catch {
+        // Ignore parse errors
+      }
+    }
+  }, [currentWorkspace, id, isNew])
+
+  // Save draft on changes
+  useEffect(() => {
+    if (!currentWorkspace || !hasChanges) return
+
+    const draftKey = `script_draft_${currentWorkspace.id}_${id || 'new'}`
+    const draft = { name, description, code }
+    localStorage.setItem(draftKey, JSON.stringify(draft))
+  }, [currentWorkspace, id, name, description, code, hasChanges])
+
+  // Handle code change
+  const handleCodeChange = useCallback((value: string | undefined) => {
+    setCode(value || '')
+    setHasChanges(true)
+  }, [])
+
+  // Configure Monaco editor
+  const handleEditorDidMount = useCallback((editor: any, monaco: any) => {
+    editorRef.current = editor
+
+    // Add TypeScript definitions
+    monaco.languages.typescript.javascriptDefaults.addExtraLib(
+      SCRIPT_TYPE_DEFS,
+      'smartbotic.d.ts'
+    )
+
+    // Configure JavaScript language
+    monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
+      target: monaco.languages.typescript.ScriptTarget.ES2020,
+      allowNonTsExtensions: true,
+      checkJs: true,
+      allowJs: true,
+    })
+
+    // Configure diagnostics
+    monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
+      noSemanticValidation: false,
+      noSyntaxValidation: false,
+    })
+  }, [])
+
+  // Handle save
+  const handleSave = async () => {
+    if (!currentWorkspace) return
+
+    if (!name.trim()) {
+      setError('Script name is required')
+      return
+    }
+
+    setIsSaving(true)
+    setError(null)
+
+    try {
+      if (isNew) {
+        const newScript = await scriptsApi.create(currentWorkspace.id, {
+          name,
+          description,
+          code,
+          config,
+          triggers,
+        })
+
+        // Clear draft
+        const draftKey = `script_draft_${currentWorkspace.id}_new`
+        localStorage.removeItem(draftKey)
+
+        // Navigate to edit page
+        navigate(`/scripts/${newScript.id}/edit`, { replace: true })
+      } else if (id) {
+        await scriptsApi.update(currentWorkspace.id, id, {
+          name,
+          description,
+          code,
+          status,
+          config,
+          triggers,
+        })
+
+        // Clear draft
+        const draftKey = `script_draft_${currentWorkspace.id}_${id}`
+        localStorage.removeItem(draftKey)
+
+        setHasChanges(false)
+      }
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to save script')
+    } finally {
+      setIsSaving(false)
+    }
+  }
+
+  // Handle test execution
+  const handleExecute = async () => {
+    if (!currentWorkspace || !id || isNew) return
+
+    setIsExecuting(true)
+    setShowRightPanel(true)
+
+    try {
+      const execution = await scriptsApi.execute(currentWorkspace.id, id)
+      setLastExecution(execution)
+
+      // Poll for completion if running
+      if (execution.status === 'running') {
+        const pollExecution = async () => {
+          try {
+            const updated = await scriptsApi.getExecution(currentWorkspace.id, execution.id)
+            setLastExecution(updated)
+            if (updated.status === 'running') {
+              setTimeout(pollExecution, 500)
+            } else {
+              setIsExecuting(false)
+            }
+          } catch {
+            setIsExecuting(false)
+          }
+        }
+        setTimeout(pollExecution, 500)
+      } else {
+        setIsExecuting(false)
+      }
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to execute script')
+      setIsExecuting(false)
+    }
+  }
+
+  // Check permissions
+  const canEdit = isNew ? canCreateScript() : canEditScript(createdBy)
+  const canTest = !isNew && canExecuteScript() && status !== 'draft'
+
+  if (!currentWorkspace) {
+    return (
+      <div className="flex h-full items-center justify-center">
+        <p className="text-gray-500">Please select a workspace first.</p>
+      </div>
+    )
+  }
+
+  if (isLoading) {
+    return (
+      <div className="flex h-full items-center justify-center">
+        <svg
+          className="h-8 w-8 animate-spin text-primary-600"
+          xmlns="http://www.w3.org/2000/svg"
+          fill="none"
+          viewBox="0 0 24 24"
+        >
+          <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
+          <path
+            className="opacity-75"
+            fill="currentColor"
+            d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
+          />
+        </svg>
+      </div>
+    )
+  }
+
+  return (
+    <div className="flex h-full flex-col">
+      {/* Header */}
+      <div className="flex items-center justify-between border-b bg-white px-4 py-3">
+        <div className="flex items-center gap-4">
+          <button
+            onClick={() => navigate('/scripts')}
+            className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
+          >
+            <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
+            </svg>
+          </button>
+          <div>
+            <h1 className="text-lg font-semibold text-gray-900">
+              {isNew ? 'New Script' : name || 'Untitled Script'}
+            </h1>
+            {hasChanges && (
+              <span className="text-xs text-yellow-600">Unsaved changes</span>
+            )}
+          </div>
+        </div>
+
+        <div className="flex items-center gap-2">
+          {/* Test button */}
+          {canTest && (
+            <Button
+              variant="secondary"
+              onClick={handleExecute}
+              disabled={isExecuting}
+              isLoading={isExecuting}
+            >
+              <svg className="-ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
+                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
+              </svg>
+              Test
+            </Button>
+          )}
+
+          {/* Toggle right panel */}
+          <button
+            onClick={() => setShowRightPanel(!showRightPanel)}
+            className={`rounded p-2 ${showRightPanel ? 'bg-primary-100 text-primary-600' : 'text-gray-400 hover:bg-gray-100 hover:text-gray-600'}`}
+            title={showRightPanel ? 'Hide execution panel' : 'Show execution panel'}
+          >
+            <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
+            </svg>
+          </button>
+
+          {/* Save button */}
+          {canEdit && (
+            <Button onClick={handleSave} disabled={isSaving} isLoading={isSaving}>
+              {isNew ? 'Create Script' : 'Save Changes'}
+            </Button>
+          )}
+        </div>
+      </div>
+
+      {/* Error */}
+      {error && (
+        <div className="border-b bg-red-50 px-4 py-2 text-sm text-red-700">{error}</div>
+      )}
+
+      {/* Main content */}
+      <div className="flex flex-1 overflow-hidden">
+        {/* Left sidebar - Config */}
+        <div className="w-72 flex-shrink-0 overflow-y-auto border-r bg-gray-50 p-4">
+          <ConfigPanel
+            name={name}
+            description={description}
+            status={status}
+            config={config}
+            onNameChange={(v: string) => { setName(v); setHasChanges(true) }}
+            onDescriptionChange={(v: string) => { setDescription(v); setHasChanges(true) }}
+            onStatusChange={(v: ScriptStatus) => { setStatus(v); setHasChanges(true) }}
+            onConfigChange={(v: ScriptConfig) => { setConfig(v); setHasChanges(true) }}
+            disabled={!canEdit}
+            isNew={isNew}
+          />
+
+          <div className="mt-6">
+            <TriggerEditor
+              triggers={triggers}
+              onChange={(v: ScriptTrigger[]) => { setTriggers(v); setHasChanges(true) }}
+              disabled={!canEdit}
+              workspaceId={currentWorkspace.id}
+            />
+          </div>
+        </div>
+
+        {/* Center - Monaco Editor */}
+        <div className="flex-1 overflow-hidden">
+          <Editor
+            height="100%"
+            language="javascript"
+            theme="vs-light"
+            value={code}
+            onChange={handleCodeChange}
+            onMount={handleEditorDidMount}
+            options={{
+              minimap: { enabled: true },
+              fontSize: 14,
+              lineNumbers: 'on',
+              wordWrap: 'on',
+              automaticLayout: true,
+              scrollBeyondLastLine: false,
+              readOnly: !canEdit,
+              tabSize: 2,
+              insertSpaces: true,
+              folding: true,
+              renderWhitespace: 'selection',
+              bracketPairColorization: { enabled: true },
+            }}
+          />
+        </div>
+
+        {/* Right panel - Execution */}
+        {showRightPanel && (
+          <div className="w-80 flex-shrink-0 overflow-y-auto border-l bg-gray-50 p-4">
+            <ExecutionPanel
+              execution={lastExecution}
+              isExecuting={isExecuting}
+              onExecute={handleExecute}
+              canExecute={canTest}
+              scriptId={id}
+              workspaceId={currentWorkspace.id}
+            />
+          </div>
+        )}
+      </div>
+    </div>
+  )
+}
+
+export default ScriptEdit

+ 411 - 0
webui/src/pages/Scripts.tsx

@@ -0,0 +1,411 @@
+// Scripts management page - list and manage automation scripts
+// Scripts are JavaScript functions that run on triggers (events, cron) or manually
+
+import { useState, useEffect, useCallback, useMemo } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { useWorkspace } from '@/contexts/WorkspaceContext'
+import { useAuth } from '@/contexts/AuthContext'
+import { usePermissions } from '@/hooks/usePermissions'
+import { useRealtimeDocuments } from '@/hooks/useRealtimeDocuments'
+import scriptsApi from '@/api/scripts'
+import Button from '@/components/Button'
+import Input from '@/components/Input'
+import type { ScriptListItem, ScriptStatus } from '@/types/scripts'
+import { STATUS_LABELS, STATUS_COLORS } from '@/types/scripts'
+
+// Status badge component
+function StatusBadge({ status }: { status: ScriptStatus }) {
+  return (
+    <span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${STATUS_COLORS[status]}`}>
+      {STATUS_LABELS[status]}
+    </span>
+  )
+}
+
+// Trigger type icon and label
+function TriggerInfo({ script }: { script: ScriptListItem }) {
+  const triggerCount = script.trigger_count || 0
+
+  return (
+    <div className="flex items-center gap-2 text-sm text-gray-500">
+      <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
+      </svg>
+      <span>{triggerCount} trigger{triggerCount !== 1 ? 's' : ''}</span>
+    </div>
+  )
+}
+
+// Format relative time
+function formatRelativeTime(dateStr: string): string {
+  const date = new Date(dateStr)
+  const now = new Date()
+  const diffMs = now.getTime() - date.getTime()
+  const diffSecs = Math.floor(diffMs / 1000)
+  const diffMins = Math.floor(diffSecs / 60)
+  const diffHours = Math.floor(diffMins / 60)
+  const diffDays = Math.floor(diffHours / 24)
+
+  if (diffSecs < 60) return 'just now'
+  if (diffMins < 60) return `${diffMins}m ago`
+  if (diffHours < 24) return `${diffHours}h ago`
+  if (diffDays < 7) return `${diffDays}d ago`
+  return date.toLocaleDateString()
+}
+
+// Main Scripts component
+function Scripts() {
+  const navigate = useNavigate()
+  const { currentWorkspace } = useWorkspace()
+  const { user } = useAuth()
+  const { canCreateScript, canEditScript, canDeleteScript, canExecuteScript } = usePermissions({
+    workspaceId: currentWorkspace?.id,
+  })
+
+  const [scripts, setScripts] = useState<ScriptListItem[]>([])
+  const [isLoading, setIsLoading] = useState(true)
+  const [error, setError] = useState<string | null>(null)
+  const [searchQuery, setSearchQuery] = useState('')
+  const [statusFilter, setStatusFilter] = useState<ScriptStatus | 'all'>('all')
+
+  // Delete confirmation state
+  const [deletingScript, setDeletingScript] = useState<ScriptListItem | null>(null)
+  const [isDeleting, setIsDeleting] = useState(false)
+
+  // Fetch scripts
+  const fetchScripts = useCallback(async () => {
+    if (!currentWorkspace) {
+      setScripts([])
+      setIsLoading(false)
+      return
+    }
+
+    setIsLoading(true)
+    setError(null)
+
+    try {
+      const response = await scriptsApi.list(currentWorkspace.id)
+      setScripts(response.scripts || [])
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to fetch scripts')
+    } finally {
+      setIsLoading(false)
+    }
+  }, [currentWorkspace])
+
+  useEffect(() => {
+    fetchScripts()
+  }, [fetchScripts])
+
+  // Real-time updates: subscribe to script changes
+  useRealtimeDocuments({
+    workspaceId: currentWorkspace?.id || '',
+    collectionName: '_scripts',
+    enabled: !!currentWorkspace?.id,
+    onEvent: (event) => {
+      if (event.action === 'create' || event.action === 'update' || event.action === 'delete') {
+        fetchScripts()
+      }
+    },
+  })
+
+  // Filter scripts based on search and status
+  const filteredScripts = useMemo(() => {
+    const query = searchQuery.toLowerCase()
+    return scripts.filter((s) => {
+      const matchesSearch =
+        s.name.toLowerCase().includes(query) ||
+        s.description.toLowerCase().includes(query)
+      const matchesStatus = statusFilter === 'all' || s.status === statusFilter
+      return matchesSearch && matchesStatus
+    })
+  }, [scripts, searchQuery, statusFilter])
+
+  // Handle status toggle
+  const handleStatusToggle = async (script: ScriptListItem) => {
+    if (!currentWorkspace) return
+
+    const newStatus: ScriptStatus = script.status === 'active' ? 'inactive' : 'active'
+
+    try {
+      await scriptsApi.update(currentWorkspace.id, script.id, { status: newStatus })
+      fetchScripts()
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to update script status')
+    }
+  }
+
+  // Handle delete
+  const handleDelete = async () => {
+    if (!deletingScript || !currentWorkspace) return
+
+    setIsDeleting(true)
+    try {
+      await scriptsApi.delete(currentWorkspace.id, deletingScript.id)
+      setDeletingScript(null)
+      fetchScripts()
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to delete script')
+    } finally {
+      setIsDeleting(false)
+    }
+  }
+
+  // Check if user owns a script
+  const isOwner = (script: ScriptListItem) => script.created_by === user?.id
+
+  if (!currentWorkspace) {
+    return (
+      <div className="space-y-6">
+        <div>
+          <h1 className="text-2xl font-bold text-gray-900">Scripts</h1>
+          <p className="mt-1 text-gray-600">Automate workflows with JavaScript scripts</p>
+        </div>
+        <div className="rounded-lg bg-yellow-50 p-6 text-center">
+          <p className="text-yellow-800">Please select a workspace first.</p>
+        </div>
+      </div>
+    )
+  }
+
+  return (
+    <div className="space-y-6">
+      {/* Header */}
+      <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
+        <div>
+          <h1 className="text-2xl font-bold text-gray-900">Scripts</h1>
+          <p className="mt-1 text-gray-600">
+            Automate workflows in {currentWorkspace.name}
+          </p>
+        </div>
+        {canCreateScript() && (
+          <Button onClick={() => navigate('/scripts/new')}>
+            <svg className="-ml-1 mr-2 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
+            </svg>
+            Create Script
+          </Button>
+        )}
+      </div>
+
+      {/* Filters */}
+      <div className="flex flex-col gap-4 sm:flex-row sm:items-center">
+        <div className="flex-1 max-w-md">
+          <Input
+            type="search"
+            placeholder="Search scripts..."
+            value={searchQuery}
+            onChange={(e) => setSearchQuery(e.target.value)}
+          />
+        </div>
+        <div className="flex items-center gap-2">
+          <label className="text-sm font-medium text-gray-700">Status:</label>
+          <select
+            value={statusFilter}
+            onChange={(e) => setStatusFilter(e.target.value as ScriptStatus | 'all')}
+            className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
+          >
+            <option value="all">All</option>
+            <option value="active">Active</option>
+            <option value="inactive">Inactive</option>
+            <option value="draft">Draft</option>
+          </select>
+        </div>
+      </div>
+
+      {/* Error */}
+      {error && (
+        <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
+      )}
+
+      {/* Loading */}
+      {isLoading && (
+        <div className="flex items-center justify-center py-12">
+          <svg
+            className="h-8 w-8 animate-spin text-primary-600"
+            xmlns="http://www.w3.org/2000/svg"
+            fill="none"
+            viewBox="0 0 24 24"
+          >
+            <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
+            <path
+              className="opacity-75"
+              fill="currentColor"
+              d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
+            />
+          </svg>
+        </div>
+      )}
+
+      {/* Scripts table */}
+      {!isLoading && !error && (
+        <div>
+          {filteredScripts.length === 0 ? (
+            <div className="rounded-lg border-2 border-dashed border-gray-300 p-12 text-center">
+              <svg
+                className="mx-auto h-12 w-12 text-gray-400"
+                fill="none"
+                viewBox="0 0 24 24"
+                stroke="currentColor"
+              >
+                <path
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                  strokeWidth={1}
+                  d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"
+                />
+              </svg>
+              <p className="mt-4 text-gray-500">
+                {searchQuery || statusFilter !== 'all'
+                  ? 'No scripts match your filters'
+                  : 'No scripts yet'}
+              </p>
+              {!searchQuery && statusFilter === 'all' && canCreateScript() && (
+                <Button className="mt-4" onClick={() => navigate('/scripts/new')}>
+                  Create Your First Script
+                </Button>
+              )}
+            </div>
+          ) : (
+            <div className="overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm">
+              <table className="min-w-full divide-y divide-gray-200">
+                <thead className="bg-gray-50">
+                  <tr>
+                    <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
+                      Script
+                    </th>
+                    <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
+                      Status
+                    </th>
+                    <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
+                      Triggers
+                    </th>
+                    <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
+                      Updated
+                    </th>
+                    <th className="relative px-6 py-3">
+                      <span className="sr-only">Actions</span>
+                    </th>
+                  </tr>
+                </thead>
+                <tbody className="divide-y divide-gray-200 bg-white">
+                  {filteredScripts.map((script) => (
+                    <tr key={script.id} className="hover:bg-gray-50">
+                      <td className="whitespace-nowrap px-6 py-4">
+                        <div className="flex items-center">
+                          <div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-primary-100 text-primary-600">
+                            <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
+                            </svg>
+                          </div>
+                          <div className="ml-4">
+                            <div className="text-sm font-medium text-gray-900">{script.name}</div>
+                            <div className="text-sm text-gray-500 truncate max-w-xs">
+                              {script.description || 'No description'}
+                            </div>
+                            {isOwner(script) && (
+                              <span className="inline-flex items-center rounded bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-700 mt-1">
+                                Owner
+                              </span>
+                            )}
+                          </div>
+                        </div>
+                      </td>
+                      <td className="whitespace-nowrap px-6 py-4">
+                        <div className="flex items-center gap-2">
+                          <StatusBadge status={script.status} />
+                          {canEditScript(script.created_by) && script.status !== 'draft' && (
+                            <button
+                              onClick={() => handleStatusToggle(script)}
+                              className="text-gray-400 hover:text-gray-600"
+                              title={script.status === 'active' ? 'Deactivate' : 'Activate'}
+                            >
+                              <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
+                              </svg>
+                            </button>
+                          )}
+                        </div>
+                      </td>
+                      <td className="whitespace-nowrap px-6 py-4">
+                        <TriggerInfo script={script} />
+                      </td>
+                      <td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
+                        {formatRelativeTime(script.updated_at)}
+                      </td>
+                      <td className="whitespace-nowrap px-6 py-4 text-right text-sm font-medium">
+                        <div className="flex items-center justify-end gap-2">
+                          {/* Execute button */}
+                          {canExecuteScript() && script.status === 'active' && (
+                            <button
+                              onClick={() => navigate(`/scripts/${script.id}/edit?test=true`)}
+                              className="rounded p-1 text-gray-400 hover:bg-green-50 hover:text-green-600"
+                              title="Test Execute"
+                            >
+                              <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
+                                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
+                              </svg>
+                            </button>
+                          )}
+
+                          {/* Edit button */}
+                          {canEditScript(script.created_by) && (
+                            <button
+                              onClick={() => navigate(`/scripts/${script.id}/edit`)}
+                              className="rounded p-1 text-gray-400 hover:bg-primary-50 hover:text-primary-600"
+                              title="Edit"
+                            >
+                              <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
+                              </svg>
+                            </button>
+                          )}
+
+                          {/* Delete button */}
+                          {canDeleteScript(script.created_by) && (
+                            <button
+                              onClick={() => setDeletingScript(script)}
+                              className="rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-600"
+                              title="Delete"
+                            >
+                              <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
+                              </svg>
+                            </button>
+                          )}
+                        </div>
+                      </td>
+                    </tr>
+                  ))}
+                </tbody>
+              </table>
+            </div>
+          )}
+        </div>
+      )}
+
+      {/* Delete Confirmation Modal */}
+      {deletingScript && (
+        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
+          <div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
+            <h2 className="text-xl font-semibold text-gray-900">Delete Script</h2>
+            <p className="mt-2 text-gray-600">
+              Are you sure you want to delete the script "{deletingScript.name}"? This action cannot be undone.
+            </p>
+            <div className="mt-6 flex justify-end gap-3">
+              <Button variant="secondary" onClick={() => setDeletingScript(null)} disabled={isDeleting}>
+                Cancel
+              </Button>
+              <Button variant="danger" onClick={handleDelete} isLoading={isDeleting}>
+                Delete Script
+              </Button>
+            </div>
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}
+
+export default Scripts

+ 8 - 2
webui/src/types/index.ts

@@ -99,7 +99,7 @@ export interface Collection {
 }
 
 // Permission scope types
-export type PermissionScope = 'system' | 'workspace' | 'collection' | 'field' | 'page'
+export type PermissionScope = 'system' | 'workspace' | 'collection' | 'field' | 'page' | 'script'
 
 // Permission string format: <scope>:<resource>:<action>[:<qualifier>]
 export interface ParsedPermission {
@@ -115,6 +115,7 @@ export const WorkspaceActions = ['admin', 'member', 'read', 'manage_members', 'm
 export const CollectionActions = ['read_all', 'read_own', 'write_all', 'write_own', 'create', 'delete_all', 'delete_own', 'manage'] as const
 export const FieldActions = ['read', 'write'] as const
 export const PageActions = ['create', 'read_all', 'read_own', 'write_all', 'write_own', 'delete_all', 'delete_own', 'share'] as const
+export const ScriptActions = ['create', 'read_all', 'read_own', 'write_all', 'write_own', 'delete_all', 'delete_own', 'execute'] as const
 
 // System resources
 export const SystemResources = ['login', 'users', 'groups', 'system_groups', 'workspaces', 'collections', 'api_keys', 'memberships', 'views', 'pages'] as const
@@ -124,6 +125,7 @@ export type WorkspaceAction = typeof WorkspaceActions[number]
 export type CollectionAction = typeof CollectionActions[number]
 export type FieldAction = typeof FieldActions[number]
 export type PageAction = typeof PageActions[number]
+export type ScriptAction = typeof ScriptActions[number]
 export type SystemResource = typeof SystemResources[number]
 
 // Helper functions for building permission strings
@@ -147,13 +149,17 @@ export function buildPagePermission(workspaceId: string, action: PageAction): st
   return `page:${workspaceId}:${action}`
 }
 
+export function buildScriptPermission(workspaceId: string, action: ScriptAction): string {
+  return `script:${workspaceId}:${action}`
+}
+
 // Parse a permission string into its components
 export function parsePermission(permission: string): ParsedPermission | null {
   const parts = permission.split(':')
   if (parts.length < 3) return null
 
   const scope = parts[0] as PermissionScope
-  if (!['system', 'workspace', 'collection', 'field', 'page'].includes(scope)) return null
+  if (!['system', 'workspace', 'collection', 'field', 'page', 'script'].includes(scope)) return null
 
   return {
     scope,

+ 153 - 0
webui/src/types/scripts.ts

@@ -0,0 +1,153 @@
+// Script types for the automation system
+
+export type ScriptStatus = 'active' | 'inactive' | 'draft'
+export type TriggerType = 'event' | 'cron'
+export type ExecutionStatus = 'running' | 'success' | 'error' | 'timeout'
+
+export interface ScriptTrigger {
+  id: string
+  type: TriggerType
+  // For event triggers
+  entity_type?: string // 'document', 'collection', etc.
+  event_action?: string // 'create', 'update', 'delete'
+  collection_filter?: string // Optional: specific collection to match
+  // For cron triggers
+  cron_expression?: string // e.g., "0 * * * *" (hourly)
+  timezone?: string // e.g., "UTC", "Europe/Budapest"
+  // Tracking
+  last_triggered?: string
+  trigger_count?: number
+}
+
+export interface ScriptConfig {
+  timeout_ms: number // Max execution time (default 5000, max 30000)
+  max_memory_mb: number // Max memory (default 16, max 64)
+  allow_network: boolean // Allow HTTP requests
+  allow_storage: boolean // Allow key-value storage
+  allowed_collections: string[] // Collections script can access
+}
+
+export interface Script {
+  id: string
+  workspace_id: string
+  name: string
+  description: string
+  code: string
+  status: ScriptStatus
+  config: ScriptConfig
+  triggers: ScriptTrigger[]
+  created_at: string
+  updated_at: string
+  created_by: string
+}
+
+export interface ScriptListItem {
+  id: string
+  workspace_id: string
+  name: string
+  description: string
+  status: ScriptStatus
+  trigger_count: number
+  created_at: string
+  updated_at: string
+  created_by: string
+}
+
+export interface ScriptExecution {
+  id: string
+  script_id: string
+  workspace_id: string
+  trigger_type: string // 'event', 'cron', 'manual'
+  trigger_event?: Record<string, unknown>
+  status: ExecutionStatus
+  result?: string
+  error?: string
+  logs: string[]
+  started_at: string
+  completed_at?: string
+  execution_time_ms: number
+  executed_by: string
+}
+
+export interface ScriptExecutionListItem {
+  id: string
+  script_id: string
+  trigger_type: string
+  status: ExecutionStatus
+  started_at: string
+  completed_at?: string
+  execution_time_ms: number
+  executed_by: string
+}
+
+// Request/Response types
+export interface CreateScriptRequest {
+  name: string
+  description?: string
+  code?: string
+  config?: Partial<ScriptConfig>
+  triggers?: ScriptTrigger[]
+}
+
+export interface UpdateScriptRequest {
+  name?: string
+  description?: string
+  code?: string
+  status?: ScriptStatus
+  config?: Partial<ScriptConfig>
+  triggers?: ScriptTrigger[]
+}
+
+export interface ExecuteScriptRequest {
+  event?: Record<string, unknown>
+}
+
+export interface ScriptListResponse {
+  scripts: ScriptListItem[]
+  total_count: number
+}
+
+export interface ExecutionListResponse {
+  executions: ScriptExecutionListItem[]
+  total_count: number
+}
+
+// Default config values
+export const DEFAULT_SCRIPT_CONFIG: ScriptConfig = {
+  timeout_ms: 5000,
+  max_memory_mb: 16,
+  allow_network: false,
+  allow_storage: true,
+  allowed_collections: [],
+}
+
+// Status display helpers
+export const STATUS_LABELS: Record<ScriptStatus, string> = {
+  active: 'Active',
+  inactive: 'Inactive',
+  draft: 'Draft',
+}
+
+export const STATUS_COLORS: Record<ScriptStatus, string> = {
+  active: 'bg-green-100 text-green-800',
+  inactive: 'bg-gray-100 text-gray-800',
+  draft: 'bg-yellow-100 text-yellow-800',
+}
+
+export const EXECUTION_STATUS_LABELS: Record<ExecutionStatus, string> = {
+  running: 'Running',
+  success: 'Success',
+  error: 'Error',
+  timeout: 'Timeout',
+}
+
+export const EXECUTION_STATUS_COLORS: Record<ExecutionStatus, string> = {
+  running: 'bg-blue-100 text-blue-800',
+  success: 'bg-green-100 text-green-800',
+  error: 'bg-red-100 text-red-800',
+  timeout: 'bg-orange-100 text-orange-800',
+}
+
+// Entity types for event triggers
+export const ENTITY_TYPES = ['document', 'collection', 'page', 'view'] as const
+export const EVENT_ACTIONS = ['create', 'update', 'delete'] as const