donobu
Version:
Create browser automations with an LLM agent and replay them as Playwright scripts.
378 lines • 20.4 kB
JavaScript
"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