Enhance ComboHandler and orchestrator functionality with access control and error handling improvements

- Added AccessControl to ComboHandler for role-based access management.
- Implemented gas estimation for plan execution and improved gas limit checks.
- Updated execution and preparation methods to enforce step count limits and role restrictions.
- Enhanced error handling in orchestrator API endpoints with AppError for better validation feedback.
- Integrated request timeout middleware for improved request management.
- Updated Swagger documentation to reflect new API structure and parameters.
This commit is contained in:
defiQUG
2025-11-05 17:55:48 -08:00
parent f600b7b15e
commit f52313e7c6
54 changed files with 3230 additions and 208 deletions

View File

@@ -10,9 +10,12 @@ import { auditLog } from "../middleware";
export const executePlan = asyncHandler(async (req: Request, res: Response) => {
const { planId } = req.params;
const result = await executionCoordinator.executePlan(planId);
res.json(result);
try {
const result = await executionCoordinator.executePlan(planId);
res.json(result);
} catch (error: any) {
throw new AppError(ErrorType.EXTERNAL_SERVICE_ERROR, 500, "Execution failed", error.message);
}
});
/**
@@ -25,6 +28,9 @@ export const getExecutionStatus = asyncHandler(async (req: Request, res: Respons
if (executionId) {
const status = await executionCoordinator.getExecutionStatus(executionId);
if (!status) {
throw new AppError(ErrorType.NOT_FOUND_ERROR, 404, "Execution not found");
}
return res.json(status);
}
@@ -40,10 +46,12 @@ export const abortExecution = asyncHandler(async (req: Request, res: Response) =
const { planId } = req.params;
const executionId = req.query.executionId as string;
if (executionId) {
await executionCoordinator.abortExecution(executionId, planId, "User aborted");
if (!executionId) {
throw new AppError(ErrorType.VALIDATION_ERROR, 400, "executionId is required");
}
await executionCoordinator.abortExecution(executionId, planId, "User aborted");
res.json({ success: true });
});

View File

@@ -3,157 +3,135 @@ import { v4 as uuidv4 } from "uuid";
import { createHash } from "crypto";
import { validatePlan, checkStepDependencies } from "../services/planValidation";
import { storePlan, getPlanById, updatePlanSignature } from "../db/plans";
import { asyncHandler, AppError, ErrorType } from "../services/errorHandler";
import type { Plan, PlanStep } from "../types/plan";
/**
* POST /api/plans
* Create a new execution plan
* @swagger
* /api/plans:
* post:
* summary: Create a new execution plan
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [creator, steps]
* properties:
* creator: { type: string }
* steps: { type: array }
* responses:
* 201:
* description: Plan created
* 400:
* description: Validation failed
*/
export async function createPlan(req: Request, res: Response) {
try {
const plan: Plan = req.body;
// Validate plan structure
const validation = validatePlan(plan);
if (!validation.valid) {
return res.status(400).json({
error: "Invalid plan",
errors: validation.errors,
});
}
// Check step dependencies
const dependencyCheck = checkStepDependencies(plan.steps);
if (!dependencyCheck.valid) {
return res.status(400).json({
error: "Invalid step dependencies",
errors: dependencyCheck.errors,
});
}
// Generate plan ID and hash
const planId = uuidv4();
const planHash = createHash("sha256")
.update(JSON.stringify(plan))
.digest("hex");
// Store plan
const storedPlan = {
...plan,
plan_id: planId,
plan_hash: planHash,
created_at: new Date().toISOString(),
status: "pending",
};
await storePlan(storedPlan);
res.status(201).json({
plan_id: planId,
plan_hash: planHash,
});
} catch (error: any) {
res.status(500).json({
error: "Failed to create plan",
message: error.message,
});
export const createPlan = asyncHandler(async (req: Request, res: Response) => {
const plan: Plan = req.body;
// Validate plan structure
const validation = validatePlan(plan);
if (!validation.valid) {
throw new AppError(ErrorType.VALIDATION_ERROR, 400, "Invalid plan", validation.errors);
}
}
// Check step dependencies
const dependencyCheck = checkStepDependencies(plan.steps);
if (!dependencyCheck.valid) {
throw new AppError(ErrorType.VALIDATION_ERROR, 400, "Invalid step dependencies", dependencyCheck.errors);
}
// Generate plan ID and hash
const planId = uuidv4();
const planHash = createHash("sha256")
.update(JSON.stringify(plan))
.digest("hex");
// Store plan
const storedPlan = {
...plan,
plan_id: planId,
plan_hash: planHash,
created_at: new Date().toISOString(),
status: "pending",
};
await storePlan(storedPlan);
res.status(201).json({
plan_id: planId,
plan_hash: planHash,
});
});
/**
* GET /api/plans/:planId
* Get plan details
*/
export async function getPlan(req: Request, res: Response) {
try {
const { planId } = req.params;
const plan = await getPlanById(planId);
export const getPlan = asyncHandler(async (req: Request, res: Response) => {
const { planId } = req.params;
const plan = await getPlanById(planId);
if (!plan) {
return res.status(404).json({
error: "Plan not found",
});
}
res.json(plan);
} catch (error: any) {
res.status(500).json({
error: "Failed to get plan",
message: error.message,
});
if (!plan) {
throw new AppError(ErrorType.NOT_FOUND_ERROR, 404, "Plan not found");
}
}
res.json(plan);
});
/**
* POST /api/plans/:planId/signature
* Add user signature to plan
*/
export async function addSignature(req: Request, res: Response) {
try {
const { planId } = req.params;
const { signature, messageHash, signerAddress } = req.body;
export const addSignature = asyncHandler(async (req: Request, res: Response) => {
const { planId } = req.params;
const { signature, messageHash, signerAddress } = req.body;
if (!signature || !messageHash || !signerAddress) {
return res.status(400).json({
error: "Missing required fields: signature, messageHash, signerAddress",
});
}
const plan = await getPlanById(planId);
if (!plan) {
return res.status(404).json({
error: "Plan not found",
});
}
// Update plan with signature
await updatePlanSignature(planId, {
signature,
messageHash,
signerAddress,
signedAt: new Date().toISOString(),
});
res.json({
success: true,
planId,
});
} catch (error: any) {
res.status(500).json({
error: "Failed to add signature",
message: error.message,
});
if (!signature || !messageHash || !signerAddress) {
throw new AppError(ErrorType.VALIDATION_ERROR, 400, "Missing required fields: signature, messageHash, signerAddress");
}
}
const plan = await getPlanById(planId);
if (!plan) {
throw new AppError(ErrorType.NOT_FOUND_ERROR, 404, "Plan not found");
}
// Update plan with signature
await updatePlanSignature(planId, {
signature,
messageHash,
signerAddress,
signedAt: new Date().toISOString(),
});
res.json({
success: true,
planId,
});
});
/**
* POST /api/plans/:planId/validate
* Validate plan structure and dependencies
*/
export async function validatePlanEndpoint(req: Request, res: Response) {
try {
const { planId } = req.params;
const plan = await getPlanById(planId);
export const validatePlanEndpoint = asyncHandler(async (req: Request, res: Response) => {
const { planId } = req.params;
const plan = await getPlanById(planId);
if (!plan) {
return res.status(404).json({
error: "Plan not found",
});
}
const validation = validatePlan(plan);
const dependencyCheck = checkStepDependencies(plan.steps);
res.json({
valid: validation.valid && dependencyCheck.valid,
validation: validation,
dependencies: dependencyCheck,
});
} catch (error: any) {
res.status(500).json({
error: "Failed to validate plan",
message: error.message,
});
if (!plan) {
throw new AppError(ErrorType.NOT_FOUND_ERROR, 404, "Plan not found");
}
}
const validation = validatePlan(plan);
const dependencyCheck = checkStepDependencies(plan.steps);
res.json({
valid: validation.valid && dependencyCheck.valid,
validation: validation,
dependencies: dependencyCheck,
});
});

View File

@@ -0,0 +1,33 @@
import { query } from "../db/postgres";
/**
* API quota management
*/
export interface Quota {
userId: string;
planCreations: number;
planExecutions: number;
dailyLimit: number;
monthlyLimit: number;
}
/**
* Check if user has quota remaining
*/
export async function checkQuota(userId: string, type: "creation" | "execution"): Promise<boolean> {
// In production, query quota table
// For now, return true (unlimited)
return true;
}
/**
* Increment quota usage
*/
export async function incrementQuota(userId: string, type: "creation" | "execution"): Promise<void> {
// In production, update quota table
// await query(
// `UPDATE quotas SET ${type}s = ${type}s + 1 WHERE user_id = $1`,
// [userId]
// );
}

View File

@@ -1,38 +1,83 @@
import { Router } from "express";
import swaggerUi from "swagger-ui-express";
import swaggerJsdoc from "swagger-jsdoc";
const options: swaggerJsdoc.Options = {
definition: {
openapi: "3.0.0",
info: {
title: "ISO-20022 Combo Flow Orchestrator API",
version: "1.0.0",
description: "API for managing and executing financial workflow plans",
},
servers: [
{
url: "http://localhost:8080",
description: "Development server",
},
],
components: {
securitySchemes: {
ApiKeyAuth: {
type: "apiKey",
in: "header",
name: "X-API-Key",
},
},
},
},
apis: ["./src/api/**/*.ts"],
};
const specs = swaggerJsdoc(options);
/**
* Swagger/OpenAPI documentation setup
* Note: In production, use swagger-ui-express and swagger-jsdoc packages
*/
export function setupSwagger(router: Router) {
router.use("/api-docs", swaggerUi.serve);
router.get("/api-docs", swaggerUi.setup(specs));
// Swagger UI endpoint
router.get("/api-docs", (req, res) => {
res.json({
openapi: "3.0.0",
info: {
title: "ISO-20022 Combo Flow Orchestrator API",
version: "1.0.0",
description: "API for managing and executing financial workflow plans",
},
servers: [
{
url: "http://localhost:8080",
description: "Development server",
},
],
paths: {
"/api/plans": {
post: {
summary: "Create a new execution plan",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
properties: {
creator: { type: "string" },
steps: { type: "array" },
maxRecursion: { type: "number" },
maxLTV: { type: "number" },
},
},
},
},
},
responses: {
"201": {
description: "Plan created",
content: {
"application/json": {
schema: {
type: "object",
properties: {
plan_id: { type: "string" },
plan_hash: { type: "string" },
},
},
},
},
},
},
},
},
"/api/plans/{planId}": {
get: {
summary: "Get plan details",
parameters: [
{
name: "planId",
in: "path",
required: true,
schema: { type: "string" },
},
],
responses: {
"200": {
description: "Plan details",
},
},
},
},
},
});
});
}

View File

@@ -0,0 +1,53 @@
import { Request, Response, NextFunction } from "express";
interface ThrottleConfig {
windowMs: number;
maxRequests: number;
}
const throttleConfigs: Map<string, ThrottleConfig> = new Map();
const requestCounts: Map<string, { count: number; resetAt: number }> = new Map();
/**
* API throttling middleware
*/
export function apiThrottle(config: ThrottleConfig) {
return (req: Request, res: Response, next: NextFunction) => {
const key = req.headers["x-api-key"] as string || req.ip || "unknown";
const now = Date.now();
let record = requestCounts.get(key);
if (!record || now > record.resetAt) {
record = {
count: 0,
resetAt: now + config.windowMs,
};
requestCounts.set(key, record);
}
record.count++;
// Set rate limit headers
res.setHeader("X-RateLimit-Limit", config.maxRequests.toString());
res.setHeader("X-RateLimit-Remaining", Math.max(0, config.maxRequests - record.count).toString());
res.setHeader("X-RateLimit-Reset", new Date(record.resetAt).toISOString());
if (record.count > config.maxRequests) {
return res.status(429).json({
error: "Rate limit exceeded",
message: `Maximum ${config.maxRequests} requests per ${config.windowMs}ms`,
retryAfter: Math.ceil((record.resetAt - now) / 1000),
});
}
next();
};
}
/**
* Set throttle configuration for a route
*/
export function setThrottleConfig(path: string, config: ThrottleConfig) {
throttleConfigs.set(path, config);
}

View File

@@ -0,0 +1,18 @@
import { Router } from "express";
import { createPlan, getPlan, addSignature, validatePlanEndpoint } from "../plans";
import { apiVersion } from "../version";
/**
* Versioned API routes (v1)
*/
const router = Router();
router.use(apiVersion("v1"));
router.post("/", createPlan);
router.get("/:planId", getPlan);
router.post("/:planId/signature", addSignature);
router.post("/:planId/validate", validatePlanEndpoint);
export default router;

View File

@@ -14,25 +14,18 @@ const webhooks: Map<string, WebhookConfig> = new Map();
* POST /api/webhooks
* Register a webhook
*/
export async function registerWebhook(req: Request, res: Response) {
try {
const { url, secret, events } = req.body;
export const registerWebhook = asyncHandler(async (req: Request, res: Response) => {
const { url, secret, events } = req.body;
if (!url || !secret || !events || !Array.isArray(events)) {
return res.status(400).json({
error: "Invalid webhook configuration",
});
}
const webhookId = `webhook-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
webhooks.set(webhookId, { url, secret, events });
res.json({ webhookId, url, events });
} catch (error: any) {
logger.error({ error }, "Failed to register webhook");
res.status(500).json({ error: error.message });
if (!url || !secret || !events || !Array.isArray(events)) {
throw new AppError(ErrorType.VALIDATION_ERROR, 400, "Invalid webhook configuration");
}
}
const webhookId = `webhook-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
webhooks.set(webhookId, { url, secret, events });
res.json({ webhookId, url, events });
});
/**
* Send webhook notification