UNPKG

donobu

Version:

Create browser automations with an LLM agent and replay them as Playwright scripts.

378 lines 20.4 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.AdminApiController = void 0; const express_1 = __importDefault(require("express")); const v4_1 = require("zod/v4"); const AgentsApi_1 = require("../apis/AgentsApi"); const AskAiApi_1 = require("../apis/AskAiApi"); const EnvDataApi_1 = require("../apis/EnvDataApi"); const FileUploadsApi_1 = require("../apis/FileUploadsApi"); const FlowsAiQueriesApi_1 = require("../apis/FlowsAiQueriesApi"); const FlowsApi_1 = require("../apis/FlowsApi"); const FlowsFilesApi_1 = require("../apis/FlowsFilesApi"); const FlowsToolCallsApi_1 = require("../apis/FlowsToolCallsApi"); const GptConfigsApi_1 = require("../apis/GptConfigsApi"); const PingApi_1 = require("../apis/PingApi"); const SchemaApi_1 = require("../apis/SchemaApi"); const SuitesApi_1 = require("../apis/SuitesApi"); const TargetsApi_1 = require("../apis/TargetsApi"); const TestsApi_1 = require("../apis/TestsApi"); const ToolsApi_1 = require("../apis/ToolsApi"); const DonobuException_1 = require("../exceptions/DonobuException"); const Logger_1 = require("../utils/Logger"); const DonobuStack_1 = require("./DonobuStack"); /** * Central API controller responsible for setting up and managing the complete Donobu HTTP API server. * * The AdminApiController serves as the main orchestrator for the Donobu REST API, handling * the initialization of the Express.js framework, middleware configuration, route registration, * and dependency injection of all API controllers. It provides a unified entry point for * starting the HTTP server and manages the complete request/response lifecycle. * * **Architecture Overview:** * - **Dependency Management**: Initializes and wires together all API controllers and managers * - **Middleware Pipeline**: Configures JSON parsing, authentication, caching, logging, and error handling * - **Route Registration**: Sets up all REST endpoints with proper HTTP methods and handlers * - **Environment Adaptation**: Adjusts available endpoints based on deployment environment * - **Error Handling**: Provides comprehensive error processing with proper HTTP status codes * * **Deployment Environment Support:** * - **LOCAL**: Full API access including flow control, tool call proposals, and auth management * - **DONOBU_HOSTED_SINGLE_TENANT**: Standard API with restricted administrative operations * - **DONOBU_HOSTED_MULTI_TENANT**: Standard API with restricted administrative operations * * **Security Features:** * - Environment-specific endpoint restrictions for security isolation * - Request context management with authentication token handling * * **Request Processing Pipeline:** * 1. **JSON Body Parsing**: 1MB limit with malformed JSON detection * 2. **Authentication Middleware**: Token extraction and context establishment * 3. **Cache Control**: Prevents API response caching * 4. **Access Logging**: Request/response tracking for monitoring * 5. **Route Handling**: Endpoint-specific business logic execution * 6. **Error Processing**: Standardized error responses with appropriate status codes * * **Error Handling Strategy:** * - **Zod Validation Errors**: 400 Bad Request with detailed field-level errors * - **Donobu Business Exceptions**: Mapped to appropriate HTTP status codes with user-friendly messages * - **Unexpected Errors**: 500 Internal Server Error with sanitized error information * - **Development vs Production**: Enhanced error details in development environments */ class AdminApiController { /** * Creates a new instance. * * @param donobuDeploymentEnvironment The environment in which this application is running in. * @param controlPanelFactory A factory for creating control panel for flows. This is injected * because if this SDK is being run inside an Electron application, it needs to be able to * inject code that controls Electron windows, and we do not want to have Electron as a * compile-time or runtime dependency for this SDK. * * This has material consequences! If this is set to a value of LOCAL then... * - additional API endpoints are registered, of which allow operations that would * otherwise be considered unsafe. * - operations around active flows are considered fair game. * - no checking for flow ownership is performed, as all flows are considered owned by the * local environment. */ static async create(donobuDeploymentEnvironment, controlPanelFactory, environ) { const expressApp = await this.setupExpressFramework(donobuDeploymentEnvironment, controlPanelFactory, environ); return new AdminApiController(expressApp); } constructor(app) { this.app = app; } /** * Binds the API/web-asset server to `port` and resolves once the socket is * listening. If the given port is 0, a random port is assigned. Rejects on * bind errors (e.g. EADDRINUSE) so callers — particularly those doing a * stop/start bounce — can recover instead of crashing on an uncaught * 'error' event. */ async start(port) { Logger_1.appLogger.debug(`Starting AdminController on port ${port}`); await new Promise((resolve, reject) => { this.server = this.app.listen(port, () => resolve()); this.server.once('error', reject); }); } /** * Stops accepting new connections and forcibly tears down any in-flight * ones, then resolves. Forceful close keeps bounces predictable — a slow * or stuck client request can't hold the port open. Callers that want to * let in-flight work drain should do so before invoking stop(). */ async stop() { if (this.server) { const address = this.server.address(); const port = typeof address === 'string' ? address : address?.port; Logger_1.appLogger.debug(`Stopping AdminController on port ${port}`); const server = this.server; await new Promise((resolve, reject) => { server.close((err) => (err ? reject(err) : resolve())); server.closeAllConnections(); }); } } static async setupExpressFramework(donobuDeploymentEnvironment, controlPanelFactory, environ) { const app = (0, express_1.default)(); const donobuStack = await (0, DonobuStack_1.setupDonobuStack)(donobuDeploymentEnvironment, controlPanelFactory, undefined, environ); const apis = this.initializeApis(donobuStack); // URL parsing error handler - must be first to catch URL decoding issues. app.use(this.setupUrlErrorHandler()); // JSON body parser with 1MB limit. app.use(this.setupJsonBodyParser()); // Disable caching for all API routes. app.use(this.setupCacheDisabler()); // Access logging middleware. app.use(this.setupAccessLogger()); // Set up the API endpoints. this.registerLocalRoutes(app, apis); this.registerCommonRoutes(app, apis); // Error handling middleware. This must happen after the endpoints have // been set up, as the order in which Express's initialization methods // are called matters. app.use(this.setupErrorHandler()); return app; } /** * Sets up URL error handler middleware to catch URL parsing/decoding errors. * Converts server errors from malformed URLs (like '%') to proper client errors. * @returns Express middleware for handling URL parsing errors */ static setupUrlErrorHandler() { return (req, res, next) => { try { // Attempt to decode the URL path to catch malformed percent encoding decodeURIComponent(req.path); next(); } catch (_error) { // If URL decoding fails, return a 400 Bad Request instead of 500 res.status(400).json({ error: 'Bad Request', message: 'Invalid URL format', }); } }; } /** * Sets up JSON body parser middleware with validation. * @returns Express middleware for parsing JSON with 1MB limit and validation */ static setupJsonBodyParser() { return express_1.default.json({ limit: '1mb', type: '*/*', strict: true, verify: (_req, res, buf, _encoding) => { try { JSON.parse(buf.toString()); } catch (_e) { res.statusCode = 400; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ error: 'Bad Request', message: 'Invalid JSON format in request body', })); throw new Error('Invalid JSON'); } }, }); } static setupCacheDisabler() { return (_req, res, next) => { res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, private'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); next(); }; } static setupAccessLogger() { return (req, res, next) => { const start = performance.now(); res.on('finish', () => { const duration = Math.round(performance.now() - start); // Turn down the log noise for the endpoints polled by the frontend. const logLevel = req.path === '/api/ping' || req.path === '/api/uploads/status' ? 'debug' : 'info'; Logger_1.accessLogger[logLevel](`${req.method} | ${req.path} | ${res.statusCode} | ${duration}ms`); }); next(); }; } static setupErrorHandler() { return (err, req, res, _next) => { // Check if response has already been sent. This can happen if some other // middleware errored out and closed the request on its own. if (res.headersSent) { return; } try { if (err instanceof v4_1.ZodError) { res.status(400).json({ code: 'INVALID_PARAM_VALUES', errors: err.issues, }); } else if (err instanceof DonobuException_1.DonobuException) { if (err.httpCode >= 500) { Logger_1.appLogger.error(`Unexpected exception when handling ${req.method} request at ${req.url}`, err); } res.status(err.httpCode).json({ code: err.donobuErrorCode, message: err.userFacingMessage, }); } else { Logger_1.appLogger.error(`Unexpected exception when handling ${req.method} request at ${req.url}`, err); res.status(500).json({ error: 'Internal Server Error', message: 'Internal Server Error', }); } } catch (error) { Logger_1.appLogger.error('Error in error handling middleware: ', error); res.status(500).send('Internal Server Error'); } }; } /** * Initializes all API handlers. */ static initializeApis(donobuStack) { return { environmentDataApi: new EnvDataApi_1.EnvDataApi(donobuStack.envDataManager), gptConfigsApi: new GptConfigsApi_1.GptConfigsApi(donobuStack.gptConfigsManager, donobuStack.agentsManager), agentsApi: new AgentsApi_1.AgentsApi(donobuStack.agentsManager), askAiApi: new AskAiApi_1.AskAiApi(donobuStack.gptClientFactory, donobuStack.gptConfigsManager, donobuStack.agentsManager), toolsApi: new ToolsApi_1.ToolsApi(donobuStack.toolRegistry), flowsApi: new FlowsApi_1.FlowsApi(donobuStack.flowsManager), flowsFilesApi: new FlowsFilesApi_1.FlowsFilesApi(donobuStack.flowsPersistenceRegistry), flowsToolCallsApi: new FlowsToolCallsApi_1.FlowsToolCallsApi(donobuStack.flowsManager), flowsAiQueriesApi: new FlowsAiQueriesApi_1.FlowsAiQueriesApi(donobuStack.flowsManager), testsApi: new TestsApi_1.TestsApi(donobuStack.testsManager, donobuStack.flowsManager), suitesApi: new SuitesApi_1.SuitesApi(donobuStack.suitesManager, donobuStack.testsManager), pingApi: new PingApi_1.PingApi(), schemaApi: new SchemaApi_1.SchemaApi(), targetsApi: new TargetsApi_1.TargetsApi(donobuStack.targetRuntimePlugins), fileUploadsApi: new FileUploadsApi_1.FileUploadsApi(), }; } /** * Registers environment-specific routes. */ static registerEnvironmentRoutes(app, environment, apis) { switch (environment) { case 'LOCAL': this.registerLocalRoutes(app, apis); break; case 'DONOBU_HOSTED_SINGLE_TENANT': case 'DONOBU_HOSTED_MULTI_TENANT': // No additional endpoints for these environments break; } } /** * Registers routes specific to LOCAL environment. */ static registerLocalRoutes(app, apis) { app.post('/api/flows/:flowId/cancel', this.asyncHandler(apis.flowsApi.cancelFlow.bind(apis.flowsApi))); app.post('/api/flows/:flowId/pause', this.asyncHandler(apis.flowsApi.pauseFlow.bind(apis.flowsApi))); app.post('/api/flows/:flowId/resume', this.asyncHandler(apis.flowsApi.resumeFlow.bind(apis.flowsApi))); app.post('/api/flows/:flowId/tool-calls', this.asyncHandler(apis.flowsToolCallsApi.postToolCalls.bind(apis.flowsToolCallsApi))); } /** * Registers common routes available in all environments. */ static registerCommonRoutes(app, apis) { // Environment routes app.get('/api/env', this.asyncHandler(apis.environmentDataApi.getEnvironmentData.bind(apis.environmentDataApi))); app.get('/api/env/:key', this.asyncHandler(apis.environmentDataApi.getEnvironmentDatum.bind(apis.environmentDataApi))); app.post('/api/env/:key', this.asyncHandler(apis.environmentDataApi.setEnvironmentDatum.bind(apis.environmentDataApi))); app.delete('/api/env/:key', this.asyncHandler(apis.environmentDataApi.deleteEnvironmentDatum.bind(apis.environmentDataApi))); // GPT Config routes app.get('/api/gpt-configs', this.asyncHandler(apis.gptConfigsApi.getAll.bind(apis.gptConfigsApi))); app.get('/api/gpt-configs/:name', this.asyncHandler(apis.gptConfigsApi.get.bind(apis.gptConfigsApi))); app.post('/api/gpt-configs/:name', this.asyncHandler(apis.gptConfigsApi.set.bind(apis.gptConfigsApi))); app.delete('/api/gpt-configs/:name', this.asyncHandler(apis.gptConfigsApi.delete.bind(apis.gptConfigsApi))); // Agent routes app.get('/api/agents', this.asyncHandler(apis.agentsApi.getAll.bind(apis.agentsApi))); app.get('/api/agents/:name', this.asyncHandler(apis.agentsApi.get.bind(apis.agentsApi))); app.post('/api/agents/:name', this.asyncHandler(apis.agentsApi.set.bind(apis.agentsApi))); // AI and Tools routes app.post('/api/ask-ai', this.asyncHandler(apis.askAiApi.ask.bind(apis.askAiApi))); app.get('/api/tools', this.asyncHandler(apis.toolsApi.getSupportedTools.bind(apis.toolsApi))); app.get('/api/targets', this.asyncHandler(apis.targetsApi.getTargets.bind(apis.targetsApi))); this.registerFlowRoutes(app, apis); this.registerTestRoutes(app, apis); this.registerSuiteRoutes(app, apis); this.registerUtilityRoutes(app, apis); } /** * Registers flow-related routes. */ static registerFlowRoutes(app, apis) { app.get('/api/flows', this.asyncHandler(apis.flowsApi.getFlows.bind(apis.flowsApi))); app.post('/api/flows', this.asyncHandler(apis.flowsApi.createFlow.bind(apis.flowsApi))); app.post('/api/flows/project', this.asyncHandler(apis.flowsApi.getFlowsAsProject.bind(apis.flowsApi))); app.get('/api/flows/:flowId', this.asyncHandler(apis.flowsApi.getFlowMetadata.bind(apis.flowsApi))); app.get('/api/flows/:flowId/rerun', this.asyncHandler(apis.flowsApi.getFlowAsRerun.bind(apis.flowsApi))); app.post('/api/flows/:flowId/rename', this.asyncHandler(apis.flowsApi.renameFlow.bind(apis.flowsApi))); app.get('/api/flows/:flowId/code', this.asyncHandler(apis.flowsApi.getFlowAsCode.bind(apis.flowsApi))); app.delete('/api/flows/:flowId', this.asyncHandler(apis.flowsApi.deleteFlow.bind(apis.flowsApi))); app.get('/api/flows/:flowId/images/:imageId', this.asyncHandler(apis.flowsFilesApi.getFlowImage.bind(apis.flowsFilesApi))); app.get('/api/flows/:flowId/browser-state', this.asyncHandler(apis.flowsFilesApi.getFlowState.bind(apis.flowsFilesApi))); app.get('/api/flows/:flowId/files/:fileId', this.asyncHandler(apis.flowsFilesApi.getFlowFile.bind(apis.flowsFilesApi))); app.get('/api/flows/:flowId/video', this.asyncHandler(apis.flowsFilesApi.getFlowVideo.bind(apis.flowsFilesApi))); app.get('/api/flows/:flowId/tool-calls', this.asyncHandler(apis.flowsToolCallsApi.getToolCalls.bind(apis.flowsToolCallsApi))); app.get('/api/flows/:flowId/tool-calls/:toolCallId', this.asyncHandler(apis.flowsToolCallsApi.getToolCall.bind(apis.flowsToolCallsApi))); app.get('/api/flows/:flowId/ai-queries', this.asyncHandler(apis.flowsAiQueriesApi.getAiQueries.bind(apis.flowsAiQueriesApi))); app.get('/api/flows/:flowId/logs', this.asyncHandler(apis.flowsApi.getFlowLogs.bind(apis.flowsApi))); } /** * Registers test-related routes. */ static registerTestRoutes(app, apis) { app.get('/api/tests', this.asyncHandler(apis.testsApi.getTests.bind(apis.testsApi))); app.post('/api/tests', this.asyncHandler(apis.testsApi.createTest.bind(apis.testsApi))); app.get('/api/tests/:testId', this.asyncHandler(apis.testsApi.getTest.bind(apis.testsApi))); app.put('/api/tests/:testId', this.asyncHandler(apis.testsApi.updateTest.bind(apis.testsApi))); app.delete('/api/tests/:testId', this.asyncHandler(apis.testsApi.deleteTest.bind(apis.testsApi))); app.get('/api/tests/:testId/flows', this.asyncHandler(apis.testsApi.getTestFlows.bind(apis.testsApi))); app.post('/api/tests/:testId/run', this.asyncHandler(apis.testsApi.runTest.bind(apis.testsApi))); } /** * Registers suite-related routes. */ static registerSuiteRoutes(app, apis) { app.get('/api/suites', this.asyncHandler(apis.suitesApi.getSuites.bind(apis.suitesApi))); app.post('/api/suites', this.asyncHandler(apis.suitesApi.createSuite.bind(apis.suitesApi))); app.get('/api/suites/:suiteId', this.asyncHandler(apis.suitesApi.getSuite.bind(apis.suitesApi))); app.put('/api/suites/:suiteId', this.asyncHandler(apis.suitesApi.updateSuite.bind(apis.suitesApi))); app.delete('/api/suites/:suiteId', this.asyncHandler(apis.suitesApi.deleteSuite.bind(apis.suitesApi))); app.get('/api/suites/:suiteId/tests', this.asyncHandler(apis.suitesApi.getSuiteTests.bind(apis.suitesApi))); } /** * Registers utility routes (version, ping). */ static registerUtilityRoutes(app, apis) { app.get('/api/ping', this.asyncHandler(apis.pingApi.ping.bind(apis.pingApi))); app.get('/api/schema', this.asyncHandler(apis.schemaApi.getSchema.bind(apis.schemaApi))); app.get('/api/uploads/status', this.asyncHandler(apis.fileUploadsApi.getStatus.bind(apis.fileUploadsApi))); } static asyncHandler(fn) { return (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); }; } } exports.AdminApiController = AdminApiController; //# sourceMappingURL=AdminApiController.js.map