Some checks failed
CI / Frontend Lint (push) Has been cancelled
CI / Frontend Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Frontend E2E Tests (push) Has been cancelled
CI / Orchestrator Build (push) Has been cancelled
CI / Contracts Compile (push) Has been cancelled
CI / Contracts Test (push) Has been cancelled
Security Scan / Dependency Vulnerability Scan (push) Has been cancelled
Security Scan / OWASP ZAP Scan (push) Has been cancelled
307 lines
8.2 KiB
TypeScript
307 lines
8.2 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
|