Files
CurrenciCombo/orchestrator/src/api/plans.ts
nsatoshi cb376eda31
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
PR D: typed + signed event bus + events table + SSE (arch step 5) (#8)
2026-04-22 17:17:40 +00:00

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();
});
});