PR D: typed + signed event bus + events table + SSE (arch step 5) (#8)
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

This commit was merged in pull request #8.
This commit is contained in:
2026-04-22 17:17:40 +00:00
parent b4d28c77d8
commit cb376eda31
6 changed files with 478 additions and 1 deletions

View File

@@ -5,6 +5,11 @@ 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";
/**
@@ -220,3 +225,82 @@ export const getPlanState = asyncHandler(async (req: Request, res: Response) =>
});
});
/**
* 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();
});
});