import type { Request, Response } from "express"; import { v4 as uuidv4 } from "uuid"; import { createHash } from "crypto"; import { validatePlan, checkStepDependencies } from "../services/planValidation"; import { storePlan, getPlanById, updatePlanSignature, listPlans } from "../db/plans"; import { asyncHandler, AppError, ErrorType } from "../services/errorHandler"; import { getTransactionState, getTransitionHistory } from "../services/stateMachine"; import { getEventsForPlan, subscribe as subscribeToEvents, verifyChain, } from "../services/eventBus"; import type { Plan, PlanStep } from "../types/plan"; /** * GET /api/plans * List all plans (with optional query parameters) * * @swagger * /api/plans: * get: * summary: List all execution plans * parameters: * - in: query * name: creator * schema: * type: string * description: Filter by creator * - in: query * name: status * schema: * type: string * description: Filter by status * - in: query * name: limit * schema: * type: integer * description: Limit number of results * - in: query * name: offset * schema: * type: integer * description: Offset for pagination * responses: * 200: * description: List of plans * * @param req - Express request with optional query parameters * @param res - Express response * @returns Array of plans */ export const listPlansEndpoint = asyncHandler(async (req: Request, res: Response) => { const creator = req.query.creator as string | undefined; const status = req.query.status as string | undefined; const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : undefined; const offset = req.query.offset ? parseInt(req.query.offset as string, 10) : undefined; const plans = await listPlans({ creator, status, limit: limit || 50, // Default limit offset, }); res.json(plans); }); /** * 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 * * @param req - Express request with plan data in body * @param res - Express response * @returns Created plan with plan_id and plan_hash * @throws AppError if validation fails */ 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 const getPlan = asyncHandler(async (req: Request, res: Response) => { const { planId } = req.params; const plan = await getPlanById(planId); 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 const addSignature = asyncHandler(async (req: Request, res: Response) => { const { planId } = req.params; const { signature, messageHash, signerAddress } = req.body; 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 const validatePlanEndpoint = asyncHandler(async (req: Request, res: Response) => { const { planId } = req.params; const plan = await getPlanById(planId); 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, }); }); /** * GET /api/plans/:planId/state * Return the current workflow state + full state-transition history. * Arch note §8 + §14 (audit chain). */ export const getPlanState = asyncHandler(async (req: Request, res: Response) => { const { planId } = req.params; const plan = await getPlanById(planId); if (!plan) { throw new AppError(ErrorType.NOT_FOUND_ERROR, 404, "Plan not found"); } const [state, history] = await Promise.all([ getTransactionState(planId), getTransitionHistory(planId), ]); res.json({ plan_id: planId, transaction_state: state, legacy_status: plan.status, transitions: history, }); }); /** * GET /api/plans/:planId/events * Return the full signed + hash-chained event trail for a plan * (arch §4.5 State Registry + §7 Event Model + §14 Audit). * * Query `?verify=1` re-verifies the chain server-side and adds * { chain_valid: true|false, broken_at?: n } to the response. */ export const getPlanEvents = asyncHandler(async (req: Request, res: Response) => { const { planId } = req.params; const plan = await getPlanById(planId); if (!plan) { throw new AppError(ErrorType.NOT_FOUND_ERROR, 404, "Plan not found"); } const events = await getEventsForPlan(planId); const body: { plan_id: string; count: number; events: typeof events; chain_valid?: boolean; broken_at?: number; broken_reason?: string; } = { plan_id: planId, count: events.length, events }; if (req.query.verify === "1") { const v = await verifyChain(planId); body.chain_valid = v.ok; if (!v.ok) { body.broken_at = v.brokenAt; body.broken_reason = v.reason; } } res.json(body); }); /** * GET /api/plans/:planId/events/stream * Server-sent-events stream of live events for a single plan. */ export const streamPlanEvents = asyncHandler(async (req: Request, res: Response) => { const { planId } = req.params; const plan = await getPlanById(planId); if (!plan) { throw new AppError(ErrorType.NOT_FOUND_ERROR, 404, "Plan not found"); } res.setHeader("Content-Type", "text/event-stream"); res.setHeader("Cache-Control", "no-cache, no-transform"); res.setHeader("Connection", "keep-alive"); res.setHeader("X-Accel-Buffering", "no"); res.flushHeaders?.(); // Replay the history on connect so clients can reconstruct state // without a separate REST call. const history = await getEventsForPlan(planId); for (const e of history) { res.write(`id: ${e.id}\nevent: ${e.type}\ndata: ${JSON.stringify(e)}\n\n`); } const unsubscribe = subscribeToEvents(planId, (record) => { res.write( `id: ${record.id}\nevent: ${record.type}\ndata: ${JSON.stringify(record)}\n\n`, ); }); const keepAlive = setInterval(() => { res.write(": keep-alive\n\n"); }, 15_000); req.on("close", () => { clearInterval(keepAlive); unsubscribe(); res.end(); }); });