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:
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
33
orchestrator/src/api/quotas.ts
Normal file
33
orchestrator/src/api/quotas.ts
Normal 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]
|
||||
// );
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
53
orchestrator/src/api/throttling.ts
Normal file
53
orchestrator/src/api/throttling.ts
Normal 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);
|
||||
}
|
||||
|
||||
18
orchestrator/src/api/v1/plans.ts
Normal file
18
orchestrator/src/api/v1/plans.ts
Normal 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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user