Compare commits
2 Commits
devin/1776
...
632f309ffc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
632f309ffc | ||
|
|
f177f6f375 |
@@ -1,44 +0,0 @@
|
||||
import { query } from "../postgres";
|
||||
|
||||
/**
|
||||
* Migration 004 — idempotency keys + replay protection (arch §13,
|
||||
* §15: deterministic state transitions, idempotent event handling,
|
||||
* resilience to duplicate messages).
|
||||
*
|
||||
* A caller supplies an `Idempotency-Key` header on POST requests.
|
||||
* The server records `{ key, request_hash, response_body, status_code }`
|
||||
* on first success and replays the cached response on subsequent
|
||||
* requests with the same key. If the request body changes while the
|
||||
* key is reused the server returns 422 with `key_reused_with_different_payload`.
|
||||
*
|
||||
* Scoped by `(method, path, key)` so the same key can safely appear
|
||||
* across unrelated endpoints.
|
||||
*
|
||||
* Rows expire after 24h — enough to cover retry windows, short enough
|
||||
* to keep the table bounded.
|
||||
*/
|
||||
export async function up() {
|
||||
await query(
|
||||
`CREATE TABLE IF NOT EXISTS idempotency_keys (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
method VARCHAR(8) NOT NULL,
|
||||
path VARCHAR(512) NOT NULL,
|
||||
key VARCHAR(255) NOT NULL,
|
||||
request_hash CHAR(64) NOT NULL,
|
||||
status_code INTEGER NOT NULL,
|
||||
response_body JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMPTZ NOT NULL DEFAULT (CURRENT_TIMESTAMP + INTERVAL '24 hours'),
|
||||
UNIQUE (method, path, key)
|
||||
)`,
|
||||
);
|
||||
|
||||
await query(
|
||||
`CREATE INDEX IF NOT EXISTS idx_idempotency_expires_at
|
||||
ON idempotency_keys(expires_at)`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function down() {
|
||||
await query("DROP TABLE IF EXISTS idempotency_keys CASCADE");
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { up as up001 } from "./001_initial_schema";
|
||||
import { up as up002 } from "./002_transaction_state";
|
||||
import { up as up003 } from "./003_events";
|
||||
import { up as up004 } from "./004_idempotency_keys";
|
||||
|
||||
/**
|
||||
* Run all migrations
|
||||
@@ -11,7 +10,6 @@ export async function runMigration() {
|
||||
await up001();
|
||||
await up002();
|
||||
await up003();
|
||||
await up004();
|
||||
console.log("All migrations completed");
|
||||
} catch (error) {
|
||||
console.error("Migration failed:", error);
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
requestId,
|
||||
apiKeyAuth,
|
||||
auditLog,
|
||||
idempotencyMiddleware,
|
||||
} from "./middleware";
|
||||
import { requestTimeout } from "./middleware/timeout";
|
||||
import { logger } from "./logging/logger";
|
||||
@@ -87,7 +86,7 @@ app.use("/api", apiLimiter);
|
||||
|
||||
// Plan management endpoints
|
||||
app.get("/api/plans", listPlansEndpoint);
|
||||
app.post("/api/plans", idempotencyMiddleware, auditLog("CREATE_PLAN", "plan"), createPlan);
|
||||
app.post("/api/plans", auditLog("CREATE_PLAN", "plan"), createPlan);
|
||||
app.get("/api/plans/:planId", getPlan);
|
||||
app.get("/api/plans/:planId/state", getPlanState);
|
||||
app.get("/api/plans/:planId/events", getPlanEvents);
|
||||
@@ -98,7 +97,7 @@ app.post("/api/plans/:planId/validate", validatePlanEndpoint);
|
||||
// Execution endpoints
|
||||
import { executePlan, getExecutionStatus, abortExecution } from "./api/execution";
|
||||
import { registerWebhook } from "./api/webhooks";
|
||||
app.post("/api/plans/:planId/execute", idempotencyMiddleware, auditLog("EXECUTE_PLAN", "plan"), executePlan);
|
||||
app.post("/api/plans/:planId/execute", auditLog("EXECUTE_PLAN", "plan"), executePlan);
|
||||
app.get("/api/plans/:planId/status", getExecutionStatus);
|
||||
app.post("/api/plans/:planId/abort", auditLog("ABORT_PLAN", "plan"), abortExecution);
|
||||
app.post("/api/webhooks", registerWebhook);
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
/**
|
||||
* Idempotency-Key middleware (arch §13 security requirements,
|
||||
* §15 non-functional: idempotent event handling, replay protection).
|
||||
*
|
||||
* Contract
|
||||
* --------
|
||||
* - If the client sends `Idempotency-Key`, the server records the
|
||||
* first successful (2xx) response and replays it verbatim on
|
||||
* subsequent requests with the same key + method + path.
|
||||
* - If the same key is re-used with a different request body the
|
||||
* server returns 422 `idempotency_key_reused` — this catches
|
||||
* client bugs where a key is accidentally reused across unrelated
|
||||
* requests.
|
||||
* - Keys are scoped by `(method, path, key)` and expire after 24h.
|
||||
* - Responses are captured by shimming `res.json()` — no deep
|
||||
* integration with route handlers required.
|
||||
* - Non-2xx responses are **not** cached so transient errors can be
|
||||
* retried without poisoning the cache.
|
||||
*
|
||||
* The middleware is transport-agnostic: routes that opt in just mount
|
||||
* `idempotencyMiddleware` ahead of the handler.
|
||||
*/
|
||||
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { createHash } from "crypto";
|
||||
import { query } from "../db/postgres";
|
||||
import { logger } from "../logging/logger";
|
||||
|
||||
export const IDEMPOTENCY_HEADER = "idempotency-key";
|
||||
const KEY_PATTERN = /^[A-Za-z0-9_\-:.]{8,255}$/;
|
||||
|
||||
function hashBody(body: unknown): string {
|
||||
const canonical = body === undefined ? "" : JSON.stringify(body);
|
||||
return createHash("sha256").update(canonical).digest("hex");
|
||||
}
|
||||
|
||||
interface CachedRow {
|
||||
request_hash: string;
|
||||
status_code: number;
|
||||
response_body: unknown;
|
||||
}
|
||||
|
||||
export async function idempotencyMiddleware(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> {
|
||||
const rawKey = req.header(IDEMPOTENCY_HEADER);
|
||||
if (!rawKey) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
if (!KEY_PATTERN.test(rawKey)) {
|
||||
res.status(400).json({
|
||||
error: "idempotency_key_invalid",
|
||||
message: "Idempotency-Key must match /^[A-Za-z0-9_\\-:.]{8,255}$/",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const key = rawKey;
|
||||
const method = req.method;
|
||||
const path = req.baseUrl + req.path;
|
||||
const requestHash = hashBody(req.body);
|
||||
|
||||
try {
|
||||
const rows = await query<CachedRow>(
|
||||
`SELECT request_hash, status_code, response_body
|
||||
FROM idempotency_keys
|
||||
WHERE method = $1 AND path = $2 AND key = $3
|
||||
AND expires_at > CURRENT_TIMESTAMP
|
||||
LIMIT 1`,
|
||||
[method, path, key],
|
||||
);
|
||||
|
||||
if (rows.length > 0) {
|
||||
const cached = rows[0];
|
||||
if (cached.request_hash !== requestHash) {
|
||||
res.status(422).json({
|
||||
error: "idempotency_key_reused",
|
||||
message: "This Idempotency-Key was previously used with a different request body.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.setHeader("Idempotent-Replayed", "true");
|
||||
res.status(cached.status_code).json(cached.response_body);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
// Fail open: if the lookup fails we still process the request so
|
||||
// the caller never sees a hard 500 because the dedup table is
|
||||
// unavailable. The downside (a missed replay on the first retry)
|
||||
// is much less bad than every write failing.
|
||||
logger.warn({ err }, "[Idempotency] lookup failed, falling open");
|
||||
}
|
||||
|
||||
const originalJson = res.json.bind(res);
|
||||
res.json = (body: unknown): Response => {
|
||||
const statusCode = res.statusCode;
|
||||
// Only cache 2xx — transient 5xx / validation 4xx stays retryable.
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
// Fire-and-forget; response is already known and can be sent.
|
||||
query(
|
||||
`INSERT INTO idempotency_keys
|
||||
(method, path, key, request_hash, status_code, response_body)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb)
|
||||
ON CONFLICT (method, path, key) DO NOTHING`,
|
||||
[method, path, key, requestHash, statusCode, JSON.stringify(body)],
|
||||
).catch((err) => {
|
||||
logger.warn({ err, key, method, path }, "[Idempotency] write failed");
|
||||
});
|
||||
}
|
||||
return originalJson(body);
|
||||
};
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
/** exposed for tests */
|
||||
export const __testing = { hashBody, KEY_PATTERN };
|
||||
@@ -5,5 +5,4 @@ export { validate, sanitizeInput } from "./validation";
|
||||
export { ipWhitelist, getClientIP } from "./ipWhitelist";
|
||||
export { auditLog } from "./auditLog";
|
||||
export { sessionManager } from "./session";
|
||||
export { idempotencyMiddleware, IDEMPOTENCY_HEADER } from "./idempotency";
|
||||
|
||||
|
||||
@@ -22,12 +22,6 @@ import { createHash, createHmac } from "crypto";
|
||||
import { EventEmitter } from "events";
|
||||
import { query } from "../db/postgres";
|
||||
import { logger } from "../logging/logger";
|
||||
import {
|
||||
getEventSigner,
|
||||
legacyHmac,
|
||||
resolveSigningMode,
|
||||
type EventSignInput,
|
||||
} from "./eventSigner";
|
||||
|
||||
/**
|
||||
* Normalised event types — arch §7.2. Keep this list as the single
|
||||
@@ -116,13 +110,7 @@ export async function publish(input: PublishInput): Promise<EventRecord> {
|
||||
[input.planId],
|
||||
);
|
||||
const prevHash = prev.length > 0 ? prev[0].signature : null;
|
||||
const signInput: EventSignInput = {
|
||||
planId: input.planId,
|
||||
type: input.type,
|
||||
payloadHash,
|
||||
prevHash,
|
||||
};
|
||||
const signature = await getEventSigner().sign(signInput);
|
||||
const signature = sign(input.planId, input.type, payloadHash, prevHash);
|
||||
|
||||
const rows = await query<EventRecord>(
|
||||
`INSERT INTO events (plan_id, type, actor, payload, payload_hash, prev_hash, signature)
|
||||
@@ -183,23 +171,9 @@ export async function verifyChain(planId: string): Promise<
|
||||
if (expectedPayloadHash !== e.payload_hash) {
|
||||
return { ok: false, brokenAt: i, reason: "payload_hash mismatch" };
|
||||
}
|
||||
const signInput: EventSignInput = {
|
||||
planId: e.plan_id,
|
||||
type: e.type,
|
||||
payloadHash: e.payload_hash,
|
||||
prevHash: e.prev_hash,
|
||||
};
|
||||
const signer = getEventSigner();
|
||||
const ok = await signer.verify(signInput, e.signature);
|
||||
// Back-compat: rows persisted before EVENT_SIGNING_MODE existed are
|
||||
// HMAC, so if the active signer rejects them, fall through to the
|
||||
// legacy check before declaring a mismatch.
|
||||
if (!ok && legacyHmac(signInput) !== e.signature) {
|
||||
return {
|
||||
ok: false,
|
||||
brokenAt: i,
|
||||
reason: `signature mismatch (mode=${resolveSigningMode()})`,
|
||||
};
|
||||
const expectedSig = sign(e.plan_id, e.type, e.payload_hash, e.prev_hash);
|
||||
if (expectedSig !== e.signature) {
|
||||
return { ok: false, brokenAt: i, reason: "signature mismatch" };
|
||||
}
|
||||
prevSig = e.signature;
|
||||
}
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
/**
|
||||
* Pluggable event signer (arch §7.5, §13; gap-analysis v2 §7.5 / §10.5).
|
||||
*
|
||||
* Three interchangeable strategies, selected via EVENT_SIGNING_MODE:
|
||||
*
|
||||
* - `hmac` (default; back-compat) HMAC-SHA256 keyed by
|
||||
* EVENT_BUS_HMAC_SECRET / SESSION_SECRET.
|
||||
* - `eip712` EIP-712 typed-data signature produced via ethers (when
|
||||
* ORCHESTRATOR_PRIVATE_KEY is set) or via the HSM
|
||||
* (services/hsm.ts) if EVENT_SIGNING_HSM_KEY_ID is set
|
||||
* and the HSM sign() path returns a 65-byte secp256k1
|
||||
* compact signature. Domain = {name: "CurrenciCombo",
|
||||
* version: "1", chainId: CHAIN_138_CHAIN_ID (138 by default)}.
|
||||
* - `jws` Compact JWS with HS256 headers, keyed by
|
||||
* EVENT_BUS_HMAC_SECRET.
|
||||
*
|
||||
* All strategies produce an opaque `string` signature suitable for
|
||||
* persisting to `events.signature` (varchar). Verification is done
|
||||
* by the same strategy code path — the prior record's signature is
|
||||
* still chained via `prev_hash` regardless of the mode.
|
||||
*/
|
||||
|
||||
import { createHmac, createHash } from "crypto";
|
||||
import { ethers } from "ethers";
|
||||
import { getHSMService } from "./hsm";
|
||||
|
||||
export type SigningMode = "hmac" | "eip712" | "jws";
|
||||
|
||||
export interface EventSignInput {
|
||||
planId: string;
|
||||
type: string;
|
||||
payloadHash: string;
|
||||
prevHash: string | null;
|
||||
}
|
||||
|
||||
export interface EventSigner {
|
||||
readonly mode: SigningMode;
|
||||
sign(input: EventSignInput): Promise<string>;
|
||||
verify(input: EventSignInput, signature: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------
|
||||
* Shared canonical payload used across modes. Keeping it stable means
|
||||
* a signature produced in one mode can, in principle, be replayed into
|
||||
* a verifier configured for the same mode on another replica.
|
||||
* ----------------------------------------------------------------- */
|
||||
function canonicalMessage(input: EventSignInput): string {
|
||||
return `${input.planId}|${input.type}|${input.payloadHash}|${input.prevHash ?? ""}`;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------
|
||||
* HMAC (default / back-compat with the pre-existing signing scheme).
|
||||
* ----------------------------------------------------------------- */
|
||||
function getHmacSecret(): string {
|
||||
return (
|
||||
process.env.EVENT_BUS_HMAC_SECRET ??
|
||||
process.env.SESSION_SECRET ??
|
||||
"dev-event-bus-secret-change-in-production"
|
||||
);
|
||||
}
|
||||
|
||||
class HmacSigner implements EventSigner {
|
||||
readonly mode: SigningMode = "hmac";
|
||||
async sign(input: EventSignInput): Promise<string> {
|
||||
return createHmac("sha256", getHmacSecret())
|
||||
.update(canonicalMessage(input))
|
||||
.digest("hex");
|
||||
}
|
||||
async verify(input: EventSignInput, signature: string): Promise<boolean> {
|
||||
const expected = await this.sign(input);
|
||||
return timingSafeEqual(expected, signature);
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------
|
||||
* EIP-712 — typed-data signing via ethers. Structured according to
|
||||
* EIP-712; domain separator pins the signer to Chain-138 orchestrator.
|
||||
* ----------------------------------------------------------------- */
|
||||
function getEip712Domain() {
|
||||
const chainId = Number(process.env.CHAIN_138_CHAIN_ID ?? 138);
|
||||
return {
|
||||
name: "CurrenciCombo",
|
||||
version: "1",
|
||||
chainId,
|
||||
verifyingContract:
|
||||
process.env.NOTARY_REGISTRY_ADDRESS ??
|
||||
"0x0000000000000000000000000000000000000000",
|
||||
} as const;
|
||||
}
|
||||
|
||||
const EIP712_TYPES: Record<string, Array<{ name: string; type: string }>> = {
|
||||
Event: [
|
||||
{ name: "planId", type: "string" },
|
||||
{ name: "eventType", type: "string" },
|
||||
{ name: "payloadHash", type: "bytes32" },
|
||||
{ name: "prevHash", type: "bytes32" },
|
||||
],
|
||||
};
|
||||
|
||||
function toBytes32(hexOrHash: string | null): string {
|
||||
if (!hexOrHash) return "0x" + "0".repeat(64);
|
||||
const h = hexOrHash.startsWith("0x") ? hexOrHash.slice(2) : hexOrHash;
|
||||
// hash is sha256 (64 hex chars) — just pad/trim to 32 bytes.
|
||||
if (h.length === 64) return `0x${h}`;
|
||||
if (h.length > 64) return `0x${h.slice(0, 64)}`;
|
||||
return `0x${h.padEnd(64, "0")}`;
|
||||
}
|
||||
|
||||
class Eip712Signer implements EventSigner {
|
||||
readonly mode: SigningMode = "eip712";
|
||||
private wallet: ethers.Wallet | null = null;
|
||||
|
||||
constructor() {
|
||||
const pk = process.env.ORCHESTRATOR_PRIVATE_KEY;
|
||||
if (pk && /^0x[0-9a-fA-F]{64}$/.test(pk)) {
|
||||
this.wallet = new ethers.Wallet(pk);
|
||||
}
|
||||
}
|
||||
|
||||
private messageValue(input: EventSignInput) {
|
||||
return {
|
||||
planId: input.planId,
|
||||
eventType: input.type,
|
||||
payloadHash: toBytes32(input.payloadHash),
|
||||
prevHash: toBytes32(input.prevHash),
|
||||
};
|
||||
}
|
||||
|
||||
async sign(input: EventSignInput): Promise<string> {
|
||||
const message = this.messageValue(input);
|
||||
if (this.wallet) {
|
||||
// ethers v6: signTypedData(domain, types, value)
|
||||
return this.wallet.signTypedData(getEip712Domain(), EIP712_TYPES, message);
|
||||
}
|
||||
// HSM fallback — the mock HSM returns an opaque buffer. Wrap it
|
||||
// with the domain digest so it is still tamper-evident against
|
||||
// the canonical message.
|
||||
const digest = ethers.TypedDataEncoder.hash(
|
||||
getEip712Domain(),
|
||||
EIP712_TYPES,
|
||||
message,
|
||||
);
|
||||
const hsm = getHSMService();
|
||||
const keyId = process.env.EVENT_SIGNING_HSM_KEY_ID ?? "orchestrator";
|
||||
const raw = await hsm.sign(Buffer.from(digest.slice(2), "hex"), keyId);
|
||||
return `0x${raw.toString("hex")}`;
|
||||
}
|
||||
|
||||
async verify(input: EventSignInput, signature: string): Promise<boolean> {
|
||||
const message = this.messageValue(input);
|
||||
if (!signature.startsWith("0x")) return false;
|
||||
try {
|
||||
const recovered = ethers.verifyTypedData(
|
||||
getEip712Domain(),
|
||||
EIP712_TYPES,
|
||||
message,
|
||||
signature,
|
||||
);
|
||||
if (this.wallet) {
|
||||
return recovered.toLowerCase() === this.wallet.address.toLowerCase();
|
||||
}
|
||||
// HSM-backed: fall back to validity check only (we don't know
|
||||
// the pubkey without a separate HSM round-trip).
|
||||
return /^0x[0-9a-fA-F]+$/.test(signature);
|
||||
} catch {
|
||||
// Not an ECDSA signature (likely HSM-opaque). Return best-effort
|
||||
// by delegating to the HSM.
|
||||
const digest = ethers.TypedDataEncoder.hash(
|
||||
getEip712Domain(),
|
||||
EIP712_TYPES,
|
||||
message,
|
||||
);
|
||||
const hsm = getHSMService();
|
||||
const keyId = process.env.EVENT_SIGNING_HSM_KEY_ID ?? "orchestrator";
|
||||
const raw = Buffer.from(signature.slice(2), "hex");
|
||||
return hsm.verify(Buffer.from(digest.slice(2), "hex"), raw, keyId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------
|
||||
* JWS compact (HS256). Useful when signing has to travel through a
|
||||
* JWT-aware infrastructure layer (API gateway, service mesh).
|
||||
* ----------------------------------------------------------------- */
|
||||
function base64UrlEncode(input: Buffer | string): string {
|
||||
const buf = Buffer.isBuffer(input) ? input : Buffer.from(input);
|
||||
return buf
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
}
|
||||
|
||||
class JwsSigner implements EventSigner {
|
||||
readonly mode: SigningMode = "jws";
|
||||
async sign(input: EventSignInput): Promise<string> {
|
||||
const header = base64UrlEncode(
|
||||
JSON.stringify({ alg: "HS256", typ: "JWS", kid: "event-bus" }),
|
||||
);
|
||||
const body = base64UrlEncode(
|
||||
JSON.stringify({
|
||||
planId: input.planId,
|
||||
type: input.type,
|
||||
payloadHash: input.payloadHash,
|
||||
prevHash: input.prevHash,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
}),
|
||||
);
|
||||
const signingInput = `${header}.${body}`;
|
||||
const mac = createHmac("sha256", getHmacSecret())
|
||||
.update(signingInput)
|
||||
.digest();
|
||||
return `${signingInput}.${base64UrlEncode(mac)}`;
|
||||
}
|
||||
|
||||
async verify(input: EventSignInput, jws: string): Promise<boolean> {
|
||||
const parts = jws.split(".");
|
||||
if (parts.length !== 3) return false;
|
||||
const [header, body, sig] = parts;
|
||||
const signingInput = `${header}.${body}`;
|
||||
const expected = base64UrlEncode(
|
||||
createHmac("sha256", getHmacSecret()).update(signingInput).digest(),
|
||||
);
|
||||
if (!timingSafeEqual(expected, sig)) return false;
|
||||
// Re-check that the body still describes the same canonical event —
|
||||
// protects against a valid JWS being replayed onto a different event.
|
||||
let decoded: Record<string, unknown>;
|
||||
try {
|
||||
decoded = JSON.parse(
|
||||
Buffer.from(body.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString(),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
decoded.planId === input.planId &&
|
||||
decoded.type === input.type &&
|
||||
decoded.payloadHash === input.payloadHash &&
|
||||
(decoded.prevHash ?? null) === (input.prevHash ?? null)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------
|
||||
* Registry.
|
||||
* ----------------------------------------------------------------- */
|
||||
|
||||
function timingSafeEqual(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
// Simple constant-time compare over the hex/base64 strings.
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||
}
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
let cachedSigner: EventSigner | undefined;
|
||||
let cachedMode: SigningMode | undefined;
|
||||
|
||||
export function resolveSigningMode(): SigningMode {
|
||||
const raw = (process.env.EVENT_SIGNING_MODE ?? "hmac").toLowerCase();
|
||||
if (raw === "eip712" || raw === "jws" || raw === "hmac") return raw;
|
||||
return "hmac";
|
||||
}
|
||||
|
||||
export function getEventSigner(): EventSigner {
|
||||
const mode = resolveSigningMode();
|
||||
if (cachedSigner && cachedMode === mode) return cachedSigner;
|
||||
cachedMode = mode;
|
||||
cachedSigner =
|
||||
mode === "eip712"
|
||||
? new Eip712Signer()
|
||||
: mode === "jws"
|
||||
? new JwsSigner()
|
||||
: new HmacSigner();
|
||||
return cachedSigner;
|
||||
}
|
||||
|
||||
export function __resetSignerForTests(): void {
|
||||
cachedSigner = undefined;
|
||||
cachedMode = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compatibility helper: the pre-PR-O signing scheme was
|
||||
* `hmac_sha256(secret, canonical)` returned as hex. Keep it for
|
||||
* verifying historical rows written before EVENT_SIGNING_MODE existed.
|
||||
*/
|
||||
export function legacyHmac(input: EventSignInput): string {
|
||||
return createHmac("sha256", getHmacSecret())
|
||||
.update(canonicalMessage(input))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic sha256 over the payload — re-exported so the bus
|
||||
* doesn't have to import `crypto` directly.
|
||||
*/
|
||||
export function payloadHashOf(payload: unknown): string {
|
||||
return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
/**
|
||||
* PR O — EIP-712 / JWS event signatures (gap-analysis v2 §7.5 / §10.5).
|
||||
*
|
||||
* Covers:
|
||||
* - HMAC default mode (back-compat) — round-trip sign/verify
|
||||
* - JWS compact mode — valid signature, payload-tamper detection,
|
||||
* signature-tamper detection
|
||||
* - EIP-712 mode — round-trip via ethers wallet, tamper detection
|
||||
* - Mode switching via EVENT_SIGNING_MODE + getEventSigner()
|
||||
* - legacyHmac helper preserves the pre-PR-O signature shape
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "@jest/globals";
|
||||
import { ethers } from "ethers";
|
||||
import {
|
||||
getEventSigner,
|
||||
resolveSigningMode,
|
||||
legacyHmac,
|
||||
__resetSignerForTests,
|
||||
type EventSignInput,
|
||||
} from "../../src/services/eventSigner";
|
||||
|
||||
const TEST_KEY = "0x" + "ab".repeat(32); // deterministic test wallet
|
||||
|
||||
function makeInput(overrides: Partial<EventSignInput> = {}): EventSignInput {
|
||||
return {
|
||||
planId: "plan-x",
|
||||
type: "transaction.created",
|
||||
payloadHash: "a".repeat(64),
|
||||
prevHash: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("eventSigner — mode resolution", () => {
|
||||
beforeEach(() => {
|
||||
__resetSignerForTests();
|
||||
delete process.env.EVENT_SIGNING_MODE;
|
||||
delete process.env.ORCHESTRATOR_PRIVATE_KEY;
|
||||
});
|
||||
|
||||
it("defaults to hmac when env var absent", () => {
|
||||
expect(resolveSigningMode()).toBe("hmac");
|
||||
expect(getEventSigner().mode).toBe("hmac");
|
||||
});
|
||||
|
||||
it("honours eip712 / jws / hmac values", () => {
|
||||
for (const mode of ["eip712", "jws", "hmac"] as const) {
|
||||
__resetSignerForTests();
|
||||
process.env.EVENT_SIGNING_MODE = mode;
|
||||
expect(resolveSigningMode()).toBe(mode);
|
||||
expect(getEventSigner().mode).toBe(mode);
|
||||
}
|
||||
});
|
||||
|
||||
it("falls through to hmac on unknown mode", () => {
|
||||
process.env.EVENT_SIGNING_MODE = "bogus";
|
||||
expect(resolveSigningMode()).toBe("hmac");
|
||||
});
|
||||
});
|
||||
|
||||
describe("eventSigner — HMAC round-trip", () => {
|
||||
beforeEach(() => {
|
||||
__resetSignerForTests();
|
||||
process.env.EVENT_SIGNING_MODE = "hmac";
|
||||
process.env.EVENT_BUS_HMAC_SECRET = "test-secret";
|
||||
});
|
||||
|
||||
it("signs + verifies", async () => {
|
||||
const signer = getEventSigner();
|
||||
const input = makeInput();
|
||||
const sig = await signer.sign(input);
|
||||
expect(sig).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(await signer.verify(input, sig)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a tampered payload hash", async () => {
|
||||
const signer = getEventSigner();
|
||||
const input = makeInput();
|
||||
const sig = await signer.sign(input);
|
||||
expect(
|
||||
await signer.verify({ ...input, payloadHash: "b".repeat(64) }, sig),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("legacyHmac produces the same hex as the HMAC signer", async () => {
|
||||
const input = makeInput();
|
||||
const sig = await getEventSigner().sign(input);
|
||||
expect(legacyHmac(input)).toBe(sig);
|
||||
});
|
||||
});
|
||||
|
||||
describe("eventSigner — JWS compact", () => {
|
||||
beforeEach(() => {
|
||||
__resetSignerForTests();
|
||||
process.env.EVENT_SIGNING_MODE = "jws";
|
||||
process.env.EVENT_BUS_HMAC_SECRET = "jws-secret";
|
||||
});
|
||||
|
||||
it("produces header.body.sig shape", async () => {
|
||||
const sig = await getEventSigner().sign(makeInput());
|
||||
expect(sig.split(".")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("round-trips sign/verify", async () => {
|
||||
const signer = getEventSigner();
|
||||
const input = makeInput();
|
||||
expect(await signer.verify(input, await signer.sign(input))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a different event being presented with a valid sig", async () => {
|
||||
const signer = getEventSigner();
|
||||
const signed = await signer.sign(makeInput());
|
||||
expect(
|
||||
await signer.verify(makeInput({ type: "transaction.aborted" }), signed),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a truncated signature", async () => {
|
||||
const signer = getEventSigner();
|
||||
const sig = await signer.sign(makeInput());
|
||||
expect(await signer.verify(makeInput(), sig.slice(0, -3))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("eventSigner — EIP-712 (ethers wallet)", () => {
|
||||
beforeEach(() => {
|
||||
__resetSignerForTests();
|
||||
process.env.EVENT_SIGNING_MODE = "eip712";
|
||||
process.env.ORCHESTRATOR_PRIVATE_KEY = TEST_KEY;
|
||||
process.env.CHAIN_138_CHAIN_ID = "138";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.ORCHESTRATOR_PRIVATE_KEY;
|
||||
});
|
||||
|
||||
it("produces a 65-byte 0x-prefixed ECDSA signature", async () => {
|
||||
const sig = await getEventSigner().sign(makeInput());
|
||||
expect(sig).toMatch(/^0x[0-9a-fA-F]{130}$/);
|
||||
});
|
||||
|
||||
it("verifies as the signing wallet", async () => {
|
||||
const signer = getEventSigner();
|
||||
const input = makeInput();
|
||||
const sig = await signer.sign(input);
|
||||
expect(await signer.verify(input, sig)).toBe(true);
|
||||
// Recovered address should match the wallet derived from TEST_KEY.
|
||||
const expectedAddress = new ethers.Wallet(TEST_KEY).address;
|
||||
const recovered = ethers.verifyTypedData(
|
||||
{
|
||||
name: "CurrenciCombo",
|
||||
version: "1",
|
||||
chainId: 138,
|
||||
verifyingContract:
|
||||
process.env.NOTARY_REGISTRY_ADDRESS ??
|
||||
"0x0000000000000000000000000000000000000000",
|
||||
},
|
||||
{
|
||||
Event: [
|
||||
{ name: "planId", type: "string" },
|
||||
{ name: "eventType", type: "string" },
|
||||
{ name: "payloadHash", type: "bytes32" },
|
||||
{ name: "prevHash", type: "bytes32" },
|
||||
],
|
||||
},
|
||||
{
|
||||
planId: input.planId,
|
||||
eventType: input.type,
|
||||
payloadHash: `0x${input.payloadHash}`,
|
||||
prevHash: "0x" + "0".repeat(64),
|
||||
},
|
||||
sig,
|
||||
);
|
||||
expect(recovered.toLowerCase()).toBe(expectedAddress.toLowerCase());
|
||||
});
|
||||
|
||||
it("rejects a signature for a different event", async () => {
|
||||
const signer = getEventSigner();
|
||||
const sig = await signer.sign(makeInput());
|
||||
// Valid signature but rebound to a different event → fails because
|
||||
// the recovered address won't be our wallet.
|
||||
expect(
|
||||
await signer.verify(makeInput({ type: "transaction.committed" }), sig),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,177 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, jest } from "@jest/globals";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
|
||||
type Row = {
|
||||
method: string;
|
||||
path: string;
|
||||
key: string;
|
||||
request_hash: string;
|
||||
status_code: number;
|
||||
response_body: unknown;
|
||||
};
|
||||
|
||||
const store = new Map<string, Row>();
|
||||
|
||||
jest.mock("../../src/db/postgres", () => ({
|
||||
query: async (sql: string, params: unknown[] = []) => {
|
||||
if (sql.startsWith("SELECT request_hash")) {
|
||||
const [method, path, key] = params as [string, string, string];
|
||||
const row = store.get(`${method}|${path}|${key}`);
|
||||
return row ? [row] : [];
|
||||
}
|
||||
if (sql.startsWith("INSERT INTO idempotency_keys")) {
|
||||
const [method, path, key, request_hash, status_code, body] = params as [
|
||||
string, string, string, string, number, string,
|
||||
];
|
||||
const k = `${method}|${path}|${key}`;
|
||||
if (!store.has(k)) {
|
||||
store.set(k, {
|
||||
method,
|
||||
path,
|
||||
key,
|
||||
request_hash,
|
||||
status_code,
|
||||
response_body: JSON.parse(body),
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
}));
|
||||
|
||||
import { idempotencyMiddleware, IDEMPOTENCY_HEADER } from "../../src/middleware/idempotency";
|
||||
|
||||
function makeReqRes(overrides: {
|
||||
header?: string;
|
||||
method?: string;
|
||||
baseUrl?: string;
|
||||
path?: string;
|
||||
body?: unknown;
|
||||
}) {
|
||||
const req = {
|
||||
method: overrides.method ?? "POST",
|
||||
baseUrl: overrides.baseUrl ?? "",
|
||||
path: overrides.path ?? "/api/plans",
|
||||
body: overrides.body ?? { a: 1 },
|
||||
header(name: string) {
|
||||
return name.toLowerCase() === IDEMPOTENCY_HEADER ? overrides.header : undefined;
|
||||
},
|
||||
} as unknown as Request;
|
||||
const captured: { status?: number; body?: unknown; headers: Record<string, string> } = {
|
||||
headers: {},
|
||||
};
|
||||
const res: Partial<Response> = {
|
||||
statusCode: 200,
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
captured.status = code;
|
||||
return this as Response;
|
||||
},
|
||||
json(body: unknown) {
|
||||
captured.body = body;
|
||||
if (captured.status === undefined) captured.status = this.statusCode;
|
||||
return this as Response;
|
||||
},
|
||||
setHeader(name: string, value: string | number | readonly string[]) {
|
||||
captured.headers[name] = String(value);
|
||||
return this as Response;
|
||||
},
|
||||
};
|
||||
return { req, res: res as Response, captured };
|
||||
}
|
||||
|
||||
describe("Idempotency middleware", () => {
|
||||
beforeEach(() => {
|
||||
store.clear();
|
||||
});
|
||||
|
||||
it("skips when no Idempotency-Key header is set", async () => {
|
||||
const { req, res } = makeReqRes({});
|
||||
const next = jest.fn() as unknown as NextFunction;
|
||||
await idempotencyMiddleware(req, res, next);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects malformed keys with 400", async () => {
|
||||
const { req, res, captured } = makeReqRes({ header: "short" });
|
||||
const next = jest.fn() as unknown as NextFunction;
|
||||
await idempotencyMiddleware(req, res, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(captured.status).toBe(400);
|
||||
expect((captured.body as { error: string }).error).toBe("idempotency_key_invalid");
|
||||
});
|
||||
|
||||
it("caches 2xx responses on first call and replays on second", async () => {
|
||||
const key = "ABC12345_test-key";
|
||||
|
||||
const first = makeReqRes({ header: key });
|
||||
const next1 = jest.fn() as unknown as NextFunction;
|
||||
await idempotencyMiddleware(first.req, first.res, next1);
|
||||
expect(next1).toHaveBeenCalledTimes(1);
|
||||
// Simulate handler sending JSON response
|
||||
first.res.status(201);
|
||||
first.res.json({ plan_id: "p-1", created: true });
|
||||
|
||||
// Let the fire-and-forget INSERT microtask flush
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
const second = makeReqRes({ header: key });
|
||||
const next2 = jest.fn() as unknown as NextFunction;
|
||||
await idempotencyMiddleware(second.req, second.res, next2);
|
||||
expect(next2).not.toHaveBeenCalled();
|
||||
expect(second.captured.status).toBe(201);
|
||||
expect(second.captured.body).toEqual({ plan_id: "p-1", created: true });
|
||||
expect(second.captured.headers["Idempotent-Replayed"]).toBe("true");
|
||||
});
|
||||
|
||||
it("rejects reuse with a different body as 422", async () => {
|
||||
const key = "ABC12345_test-key";
|
||||
|
||||
const first = makeReqRes({ header: key, body: { a: 1 } });
|
||||
const next1 = jest.fn() as unknown as NextFunction;
|
||||
await idempotencyMiddleware(first.req, first.res, next1);
|
||||
first.res.status(200);
|
||||
first.res.json({ ok: true });
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
const second = makeReqRes({ header: key, body: { a: 2 } });
|
||||
const next2 = jest.fn() as unknown as NextFunction;
|
||||
await idempotencyMiddleware(second.req, second.res, next2);
|
||||
expect(next2).not.toHaveBeenCalled();
|
||||
expect(second.captured.status).toBe(422);
|
||||
expect((second.captured.body as { error: string }).error).toBe("idempotency_key_reused");
|
||||
});
|
||||
|
||||
it("does NOT cache non-2xx responses (retryable)", async () => {
|
||||
const key = "ABC12345_test-key";
|
||||
|
||||
const first = makeReqRes({ header: key });
|
||||
await idempotencyMiddleware(first.req, first.res, jest.fn() as unknown as NextFunction);
|
||||
first.res.status(500);
|
||||
first.res.json({ error: "boom" });
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
// Retry should go through (no replay)
|
||||
const second = makeReqRes({ header: key });
|
||||
const next2 = jest.fn() as unknown as NextFunction;
|
||||
await idempotencyMiddleware(second.req, second.res, next2);
|
||||
expect(next2).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("scopes by (method, path, key)", async () => {
|
||||
const key = "ABC12345_test-key";
|
||||
|
||||
const createPlan = makeReqRes({ header: key, path: "/api/plans" });
|
||||
await idempotencyMiddleware(createPlan.req, createPlan.res, jest.fn() as unknown as NextFunction);
|
||||
createPlan.res.status(201);
|
||||
createPlan.res.json({ plan_id: "p-1" });
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
// Same key on a different path: should pass through, not replay
|
||||
const execute = makeReqRes({ header: key, path: "/api/plans/p-1/execute" });
|
||||
const nextExec = jest.fn() as unknown as NextFunction;
|
||||
await idempotencyMiddleware(execute.req, execute.res, nextExec);
|
||||
expect(nextExec).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,6 @@ import TreasuryPage from './pages/TreasuryPage';
|
||||
import ReportingPage from './pages/ReportingPage';
|
||||
import CompliancePage from './pages/CompliancePage';
|
||||
import SettlementsPage from './pages/SettlementsPage';
|
||||
import TransactionsPage from './pages/TransactionsPage';
|
||||
import PortalLayout from './components/portal/PortalLayout';
|
||||
import LiveChainBanner from './components/portal/LiveChainBanner';
|
||||
import App from './App';
|
||||
@@ -132,28 +131,6 @@ export default function Portal() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/transactions"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<PortalLayout>
|
||||
<TransactionsPage />
|
||||
</PortalLayout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/transactions/:planId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<PortalLayout>
|
||||
<TransactionsPage />
|
||||
</PortalLayout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
|
||||
@@ -4,13 +4,12 @@ import { useAuth } from '../../contexts/AuthContext';
|
||||
import {
|
||||
LayoutDashboard, Zap, Building2, Landmark, FileText, Shield, CheckSquare,
|
||||
Settings, LogOut, ChevronLeft, ChevronRight, Bell, User, Copy,
|
||||
ExternalLink, ChevronDown, GitBranch
|
||||
ExternalLink, ChevronDown
|
||||
} from 'lucide-react';
|
||||
|
||||
const navItems = [
|
||||
{ id: 'dashboard', label: 'Overview', icon: LayoutDashboard, path: '/dashboard' },
|
||||
{ id: 'transaction-builder', label: 'Transaction Builder', icon: Zap, path: '/transaction-builder' },
|
||||
{ id: 'transactions', label: 'Transactions', icon: GitBranch, path: '/transactions' },
|
||||
{ id: 'accounts', label: 'Accounts', icon: Building2, path: '/accounts' },
|
||||
{ id: 'treasury', label: 'Treasury', icon: Landmark, path: '/treasury' },
|
||||
{ id: 'reporting', label: 'Reporting', icon: FileText, path: '/reporting' },
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { TRANSACTION_STATES, type StateTransition, type TransactionState } from '../../services/orchestrator';
|
||||
|
||||
interface StateMachineViewProps {
|
||||
current: TransactionState;
|
||||
transitions: StateTransition[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the 12-state transaction machine from the architecture note
|
||||
* §8. Visited states are highlighted in the order they were entered;
|
||||
* the current state is emphasised. Intended as an audit-friendly view
|
||||
* for the /transactions page, NOT a full graph editor.
|
||||
*/
|
||||
export default function StateMachineView({ current, transitions }: StateMachineViewProps) {
|
||||
const visited = new Set<string>(transitions.map((t) => t.to_state));
|
||||
if (transitions.length > 0 && transitions[0].from_state === null) {
|
||||
visited.add(transitions[0].to_state);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="state-machine-view">
|
||||
<div className="state-machine-grid">
|
||||
{TRANSACTION_STATES.map((state) => {
|
||||
const isCurrent = state === current;
|
||||
const isVisited = visited.has(state);
|
||||
const isTerminal = state === 'COMMITTED' || state === 'ABORTED' || state === 'CLOSED';
|
||||
const classes = [
|
||||
'state-pill',
|
||||
isCurrent ? 'state-pill--current' : '',
|
||||
!isCurrent && isVisited ? 'state-pill--visited' : '',
|
||||
!isVisited ? 'state-pill--pending' : '',
|
||||
isTerminal ? 'state-pill--terminal' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
return (
|
||||
<div key={state} className={classes} data-testid={`state-${state}`}>
|
||||
<span className="state-pill-dot" aria-hidden="true" />
|
||||
<span className="state-pill-label">{state.replace(/_/g, ' ')}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="state-machine-legend">
|
||||
<span className="legend-item"><span className="dot dot--current" />current</span>
|
||||
<span className="legend-item"><span className="dot dot--visited" />visited</span>
|
||||
<span className="legend-item"><span className="dot dot--pending" />not yet reached</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -40,13 +40,6 @@ export interface EndpointConfig {
|
||||
* banking API is stood up. */
|
||||
mocked: true;
|
||||
};
|
||||
orchestrator: {
|
||||
/** CurrenciCombo/orchestrator base URL (plan-state + event stream
|
||||
* for /transactions page). Empty string means "not deployed —
|
||||
* fall back to mock demo data". */
|
||||
baseUrl: string;
|
||||
deployed: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const env = (import.meta as unknown as { env?: Record<string, string> }).env ?? {};
|
||||
@@ -73,16 +66,12 @@ export const endpoints: EndpointConfig = {
|
||||
apiBaseUrl: env.VITE_DBIS_CORE_API_BASE_URL || 'https://api.dbis-core.d-bis.org',
|
||||
mocked: true,
|
||||
},
|
||||
orchestrator: {
|
||||
baseUrl: env.VITE_ORCHESTRATOR_URL || '',
|
||||
deployed: Boolean(env.VITE_ORCHESTRATOR_URL),
|
||||
},
|
||||
};
|
||||
|
||||
export type BackendStatus = 'live' | 'bff-required' | 'mocked' | 'degraded';
|
||||
|
||||
export interface BackendDescriptor {
|
||||
id: 'chain138' | 'explorer' | 'proxmox' | 'dbisCore' | 'orchestrator';
|
||||
id: 'chain138' | 'explorer' | 'proxmox' | 'dbisCore';
|
||||
name: string;
|
||||
status: BackendStatus;
|
||||
url: string;
|
||||
@@ -118,13 +107,4 @@ export const backendCatalog: BackendDescriptor[] = [
|
||||
url: endpoints.dbisCore.apiBaseUrl,
|
||||
note: 'No public deployment yet. UI falls back to sample portal data.',
|
||||
},
|
||||
{
|
||||
id: 'orchestrator',
|
||||
name: 'Transaction Orchestrator',
|
||||
status: endpoints.orchestrator.deployed ? 'live' : 'mocked',
|
||||
url: endpoints.orchestrator.baseUrl || '(not deployed)',
|
||||
note: endpoints.orchestrator.deployed
|
||||
? 'CurrenciCombo orchestrator — plan state + event stream.'
|
||||
: 'Orchestrator not yet deployed. /transactions page renders demo plans.',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -3851,96 +3851,3 @@ html, body, #root {
|
||||
border-radius: 4px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ================================================================= */
|
||||
/* /transactions page (PR G — arch step 8) */
|
||||
/* ================================================================= */
|
||||
|
||||
.transactions-page { padding: 24px; display: flex; flex-direction: column; gap: 20px; }
|
||||
.transactions-page .back-button {
|
||||
background: none; border: none; color: var(--accent);
|
||||
cursor: pointer; font-size: 13px; padding: 0; margin-bottom: 8px;
|
||||
}
|
||||
.transactions-page .back-button:hover { text-decoration: underline; }
|
||||
|
||||
.source-badge {
|
||||
font-size: 10px; letter-spacing: 0.08em; padding: 2px 8px;
|
||||
border-radius: 10px; font-weight: 600; text-transform: uppercase;
|
||||
}
|
||||
.source-badge--live { background: rgba(34,197,94,0.15); color: #22c55e; }
|
||||
.source-badge--degraded { background: rgba(239,68,68,0.15); color: #ef4444; }
|
||||
.source-badge--mocked { background: rgba(148,163,184,0.20); color: #94a3b8; }
|
||||
|
||||
.portal-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.portal-table th, .portal-table td { padding: 10px 12px; text-align: left; border-bottom: 1px solid rgba(148,163,184,0.12); }
|
||||
.portal-table th { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: #94a3b8; font-weight: 600; }
|
||||
.portal-table tbody tr { transition: background 0.12s ease; }
|
||||
.portal-table .portal-table-row { cursor: pointer; }
|
||||
.portal-table .portal-table-row:hover { background: rgba(99,102,241,0.06); }
|
||||
.portal-table .mono { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12px; }
|
||||
.portal-table .truncate { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.portal-table .row-chevron { color: #64748b; }
|
||||
|
||||
.state-chip, .role-chip {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 3px 10px; border-radius: 999px; font-size: 11px;
|
||||
font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase;
|
||||
background: rgba(99,102,241,0.14); color: #a5b4fc;
|
||||
}
|
||||
.state-chip--committed { background: rgba(34,197,94,0.15); color: #22c55e; }
|
||||
.state-chip--aborted { background: rgba(239,68,68,0.15); color: #ef4444; }
|
||||
.state-chip--validating,
|
||||
.state-chip--executing,
|
||||
.state-chip--partially_executed { background: rgba(245,158,11,0.15); color: #f59e0b; }
|
||||
.state-chip--draft { background: rgba(148,163,184,0.18); color: #cbd5e1; }
|
||||
.state-chip--closed { background: rgba(148,163,184,0.25); color: #e2e8f0; }
|
||||
|
||||
.role-chip--submitter { background: rgba(99,102,241,0.14); color: #a5b4fc; }
|
||||
.role-chip--approver { background: rgba(245,158,11,0.14); color: #f59e0b; }
|
||||
.role-chip--releaser { background: rgba(14,165,233,0.14); color: #38bdf8; }
|
||||
.role-chip--validator { background: rgba(168,85,247,0.14); color: #c084fc; }
|
||||
.role-chip--coordinator{ background: rgba(148,163,184,0.18); color: #cbd5e1; }
|
||||
|
||||
.state-machine-view { padding: 12px 8px 4px; }
|
||||
.state-machine-grid {
|
||||
display: grid; gap: 10px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
}
|
||||
.state-pill {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 14px; border-radius: 10px;
|
||||
border: 1px solid rgba(148,163,184,0.18);
|
||||
background: rgba(15,23,42,0.35); color: #e2e8f0;
|
||||
font-size: 12px; font-weight: 500; letter-spacing: 0.03em;
|
||||
}
|
||||
.state-pill-dot {
|
||||
width: 10px; height: 10px; border-radius: 50%;
|
||||
background: rgba(148,163,184,0.45);
|
||||
}
|
||||
.state-pill--visited { border-color: rgba(99,102,241,0.35); }
|
||||
.state-pill--visited .state-pill-dot { background: #818cf8; }
|
||||
.state-pill--current {
|
||||
border-color: #22c55e;
|
||||
box-shadow: 0 0 0 2px rgba(34,197,94,0.18);
|
||||
background: rgba(34,197,94,0.08);
|
||||
}
|
||||
.state-pill--current .state-pill-dot { background: #22c55e; }
|
||||
.state-pill--pending { opacity: 0.55; }
|
||||
.state-pill--terminal.state-pill--visited { border-color: #f59e0b; }
|
||||
.state-machine-legend {
|
||||
display: flex; gap: 16px; padding: 12px 4px 0;
|
||||
font-size: 11px; color: #94a3b8;
|
||||
}
|
||||
.legend-item { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.legend-item .dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||
.legend-item .dot--current { background: #22c55e; }
|
||||
.legend-item .dot--visited { background: #818cf8; }
|
||||
.legend-item .dot--pending { background: rgba(148,163,184,0.45); }
|
||||
|
||||
.loading-row, .empty-row { padding: 20px; color: #94a3b8; text-align: center; font-size: 13px; }
|
||||
.error-banner {
|
||||
padding: 10px 14px; border-radius: 8px; font-size: 12px;
|
||||
background: rgba(239,68,68,0.10); color: #fca5a5;
|
||||
border: 1px solid rgba(239,68,68,0.25); margin: 8px 0;
|
||||
}
|
||||
.muted { color: #94a3b8; }
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { listPlans, getPlanState, getPlanEvents, type PlanSummary, type PlanStateDetail, type PlanEvent } from '../services/orchestrator';
|
||||
import StateMachineView from '../components/portal/StateMachineView';
|
||||
import { endpoints } from '../config/endpoints';
|
||||
|
||||
type Source = 'live' | 'mocked' | 'degraded';
|
||||
|
||||
function SourceBadge({ source }: { source: Source }) {
|
||||
const label = source === 'live' ? 'LIVE' : source === 'degraded' ? 'DEGRADED' : 'DEMO';
|
||||
return <span className={`source-badge source-badge--${source}`}>{label}</span>;
|
||||
}
|
||||
|
||||
export default function TransactionsPage() {
|
||||
const { planId } = useParams<{ planId?: string }>();
|
||||
return planId ? <TransactionDetail planId={planId} /> : <TransactionsList />;
|
||||
}
|
||||
|
||||
function TransactionsList() {
|
||||
const navigate = useNavigate();
|
||||
const [plans, setPlans] = useState<PlanSummary[] | null>(null);
|
||||
const [source, setSource] = useState<Source>('mocked');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setPlans(null);
|
||||
setError(null);
|
||||
listPlans()
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
setPlans(res.plans);
|
||||
setSource(res.source);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="transactions-page">
|
||||
<div className="page-header">
|
||||
<h1>Transactions</h1>
|
||||
<p className="page-subtitle">
|
||||
Multi-layer atomic settlement plans. State machine per architecture note §8.
|
||||
{!endpoints.orchestrator.deployed && (
|
||||
<span className="muted">
|
||||
{' '}Orchestrator not deployed — showing demo plans.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-card">
|
||||
<div className="card-header">
|
||||
<h3>Recent plans</h3>
|
||||
<SourceBadge source={source} />
|
||||
</div>
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
{plans === null ? (
|
||||
<div className="loading-row">Loading…</div>
|
||||
) : plans.length === 0 ? (
|
||||
<div className="empty-row">No plans yet.</div>
|
||||
) : (
|
||||
<table className="portal-table" data-testid="transactions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Plan ID</th>
|
||||
<th>State</th>
|
||||
<th>Instrument</th>
|
||||
<th>Owner</th>
|
||||
<th>Updated</th>
|
||||
<th aria-label="open" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{plans.map((p) => (
|
||||
<tr
|
||||
key={p.plan_id}
|
||||
className="portal-table-row"
|
||||
onClick={() => navigate(`/transactions/${encodeURIComponent(p.plan_id)}`)}
|
||||
data-testid={`plan-row-${p.plan_id}`}
|
||||
>
|
||||
<td className="mono">{p.plan_id}</td>
|
||||
<td>
|
||||
<span className={`state-chip state-chip--${p.status.toLowerCase()}`}>
|
||||
{p.status.replace(/_/g, ' ')}
|
||||
</span>
|
||||
</td>
|
||||
<td>{p.instrument_hint ?? '—'}</td>
|
||||
<td>{p.actor_id ?? '—'}</td>
|
||||
<td>{new Date(p.updated_at).toLocaleString()}</td>
|
||||
<td className="row-chevron">›</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TransactionDetail({ planId }: { planId: string }) {
|
||||
const navigate = useNavigate();
|
||||
const [detail, setDetail] = useState<PlanStateDetail | null>(null);
|
||||
const [events, setEvents] = useState<PlanEvent[] | null>(null);
|
||||
const [source, setSource] = useState<Source>('mocked');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setDetail(null);
|
||||
setEvents(null);
|
||||
setError(null);
|
||||
Promise.all([getPlanState(planId), getPlanEvents(planId)])
|
||||
.then(([s, e]) => {
|
||||
if (cancelled) return;
|
||||
setDetail(s.detail);
|
||||
setEvents(e.events);
|
||||
setSource(s.source === 'live' && e.source === 'live' ? 'live' : s.source);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [planId]);
|
||||
|
||||
return (
|
||||
<div className="transactions-page">
|
||||
<div className="page-header">
|
||||
<button className="back-button" onClick={() => navigate('/transactions')}>
|
||||
← All transactions
|
||||
</button>
|
||||
<h1>
|
||||
Plan <span className="mono">{planId}</span>
|
||||
</h1>
|
||||
<p className="page-subtitle">
|
||||
{detail ? (
|
||||
<>Current state: <strong>{detail.current_state.replace(/_/g, ' ')}</strong></>
|
||||
) : (
|
||||
'Loading plan state…'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
<div className="dashboard-card">
|
||||
<div className="card-header">
|
||||
<h3>12-state machine</h3>
|
||||
<SourceBadge source={source} />
|
||||
</div>
|
||||
{detail ? (
|
||||
<StateMachineView current={detail.current_state} transitions={detail.transitions} />
|
||||
) : (
|
||||
<div className="loading-row">Loading…</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="dashboard-card">
|
||||
<div className="card-header">
|
||||
<h3>Audit trail</h3>
|
||||
</div>
|
||||
{detail === null ? (
|
||||
<div className="loading-row">Loading…</div>
|
||||
) : detail.transitions.length === 0 ? (
|
||||
<div className="empty-row">No transitions recorded.</div>
|
||||
) : (
|
||||
<table className="portal-table" data-testid="audit-trail">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>From → To</th>
|
||||
<th>Actor</th>
|
||||
<th>Role</th>
|
||||
<th>Reason</th>
|
||||
<th>At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{detail.transitions.map((t, i) => (
|
||||
<tr key={i}>
|
||||
<td>{i + 1}</td>
|
||||
<td className="mono">
|
||||
{t.from_state ?? '∅'} → {t.to_state}
|
||||
</td>
|
||||
<td>{t.actor_id}</td>
|
||||
<td>
|
||||
<span className={`role-chip role-chip--${t.actor_role}`}>{t.actor_role}</span>
|
||||
</td>
|
||||
<td>{t.reason ?? '—'}</td>
|
||||
<td>{new Date(t.occurred_at).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="dashboard-card">
|
||||
<div className="card-header">
|
||||
<h3>Signed event stream</h3>
|
||||
</div>
|
||||
{events === null ? (
|
||||
<div className="loading-row">Loading…</div>
|
||||
) : events.length === 0 ? (
|
||||
<div className="empty-row">No events.</div>
|
||||
) : (
|
||||
<table className="portal-table" data-testid="event-stream">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Type</th>
|
||||
<th>Signature</th>
|
||||
<th>Prev hash</th>
|
||||
<th>At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.map((e) => (
|
||||
<tr key={e.id}>
|
||||
<td>{e.id}</td>
|
||||
<td className="mono">{e.type}</td>
|
||||
<td className="mono truncate">{e.signature}</td>
|
||||
<td className="mono truncate">{e.prev_hash ?? '∅'}</td>
|
||||
<td>{new Date(e.created_at).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
/**
|
||||
* CurrenciCombo/orchestrator API client — consumed by the portal's
|
||||
* /transactions page.
|
||||
*
|
||||
* When `VITE_ORCHESTRATOR_URL` is unset or the endpoint is unreachable,
|
||||
* every call falls back to deterministic demo data so the page still
|
||||
* renders something meaningful. This mirrors how the other services
|
||||
* treat unreachable backends (chain138, explorer, dbisCore).
|
||||
*
|
||||
* The shape of the data matches the orchestrator's API (see
|
||||
* orchestrator/src/api/plans.ts + orchestrator/src/api/eventBus.ts).
|
||||
* Re-sync if the orchestrator evolves.
|
||||
*/
|
||||
|
||||
import { endpoints } from '../config/endpoints';
|
||||
|
||||
export type TransactionState =
|
||||
| 'DRAFT'
|
||||
| 'INITIATED'
|
||||
| 'PRECONDITIONS_PENDING'
|
||||
| 'READY_FOR_PREPARE'
|
||||
| 'PREPARED'
|
||||
| 'EXECUTING'
|
||||
| 'PARTIALLY_EXECUTED'
|
||||
| 'VALIDATING'
|
||||
| 'COMMITTED'
|
||||
| 'ABORTED'
|
||||
| 'UNWIND_PENDING'
|
||||
| 'CLOSED';
|
||||
|
||||
export const TRANSACTION_STATES: TransactionState[] = [
|
||||
'DRAFT',
|
||||
'INITIATED',
|
||||
'PRECONDITIONS_PENDING',
|
||||
'READY_FOR_PREPARE',
|
||||
'PREPARED',
|
||||
'EXECUTING',
|
||||
'PARTIALLY_EXECUTED',
|
||||
'VALIDATING',
|
||||
'COMMITTED',
|
||||
'ABORTED',
|
||||
'UNWIND_PENDING',
|
||||
'CLOSED',
|
||||
];
|
||||
|
||||
export interface PlanSummary {
|
||||
plan_id: string;
|
||||
status: TransactionState;
|
||||
actor_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
instrument_hint?: string;
|
||||
}
|
||||
|
||||
export interface StateTransition {
|
||||
from_state: TransactionState | null;
|
||||
to_state: TransactionState;
|
||||
actor_id: string;
|
||||
actor_role: string;
|
||||
reason: string | null;
|
||||
occurred_at: string;
|
||||
}
|
||||
|
||||
export interface PlanStateDetail {
|
||||
plan_id: string;
|
||||
current_state: TransactionState;
|
||||
transitions: StateTransition[];
|
||||
}
|
||||
|
||||
export interface PlanEvent {
|
||||
id: number;
|
||||
plan_id: string;
|
||||
type: string;
|
||||
payload: Record<string, unknown>;
|
||||
signature: string;
|
||||
prev_hash: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
type OrchestratorStatus = 'live' | 'mocked' | 'degraded';
|
||||
|
||||
export interface OrchestratorProbe {
|
||||
status: OrchestratorStatus;
|
||||
latencyMs: number | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const base = endpoints.orchestrator.baseUrl;
|
||||
const deployed = endpoints.orchestrator.deployed;
|
||||
|
||||
async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${base}${path}`, {
|
||||
...init,
|
||||
headers: { Accept: 'application/json', ...(init?.headers ?? {}) },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`orchestrator ${res.status} on ${path}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function probeOrchestrator(): Promise<OrchestratorProbe> {
|
||||
if (!deployed) return { status: 'mocked', latencyMs: null };
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
const res = await fetch(`${base}/health`, { method: 'GET' });
|
||||
const latencyMs = Math.round(performance.now() - t0);
|
||||
if (!res.ok) return { status: 'degraded', latencyMs, error: `HTTP ${res.status}` };
|
||||
return { status: 'live', latencyMs };
|
||||
} catch (err) {
|
||||
return {
|
||||
status: 'degraded',
|
||||
latencyMs: null,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function listPlans(): Promise<{ plans: PlanSummary[]; source: OrchestratorStatus }> {
|
||||
if (!deployed) return { plans: demoPlans(), source: 'mocked' };
|
||||
try {
|
||||
const data = await fetchJson<{ plans: PlanSummary[] }>('/api/plans');
|
||||
return { plans: data.plans ?? [], source: 'live' };
|
||||
} catch {
|
||||
return { plans: demoPlans(), source: 'degraded' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPlanState(
|
||||
planId: string,
|
||||
): Promise<{ detail: PlanStateDetail; source: OrchestratorStatus }> {
|
||||
if (!deployed) return { detail: demoPlanState(planId), source: 'mocked' };
|
||||
try {
|
||||
const detail = await fetchJson<PlanStateDetail>(`/api/plans/${encodeURIComponent(planId)}/state`);
|
||||
return { detail, source: 'live' };
|
||||
} catch {
|
||||
return { detail: demoPlanState(planId), source: 'degraded' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPlanEvents(
|
||||
planId: string,
|
||||
): Promise<{ events: PlanEvent[]; source: OrchestratorStatus }> {
|
||||
if (!deployed) return { events: demoPlanEvents(planId), source: 'mocked' };
|
||||
try {
|
||||
const data = await fetchJson<{ events: PlanEvent[] }>(
|
||||
`/api/plans/${encodeURIComponent(planId)}/events`,
|
||||
);
|
||||
return { events: data.events ?? [], source: 'live' };
|
||||
} catch {
|
||||
return { events: demoPlanEvents(planId), source: 'degraded' };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Demo data — used only when VITE_ORCHESTRATOR_URL is unset. Lets the
|
||||
// /transactions page demonstrate the 12-state machine visualisation
|
||||
// without needing a deployed orchestrator.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function demoPlans(): PlanSummary[] {
|
||||
const now = Date.now();
|
||||
return [
|
||||
{
|
||||
plan_id: 'demo-sblc-001',
|
||||
status: 'VALIDATING',
|
||||
actor_id: 'ops.alice',
|
||||
created_at: new Date(now - 1000 * 60 * 55).toISOString(),
|
||||
updated_at: new Date(now - 1000 * 30).toISOString(),
|
||||
instrument_hint: 'MT760 / EIB beneficiary format',
|
||||
},
|
||||
{
|
||||
plan_id: 'demo-pay-014',
|
||||
status: 'COMMITTED',
|
||||
actor_id: 'ops.bob',
|
||||
created_at: new Date(now - 1000 * 60 * 60 * 3).toISOString(),
|
||||
updated_at: new Date(now - 1000 * 60 * 7).toISOString(),
|
||||
instrument_hint: 'pacs.009 FI-to-FI',
|
||||
},
|
||||
{
|
||||
plan_id: 'demo-sblc-003',
|
||||
status: 'ABORTED',
|
||||
actor_id: 'ops.alice',
|
||||
created_at: new Date(now - 1000 * 60 * 60 * 8).toISOString(),
|
||||
updated_at: new Date(now - 1000 * 60 * 60 * 2).toISOString(),
|
||||
instrument_hint: 'MT202 COV',
|
||||
},
|
||||
{
|
||||
plan_id: 'demo-draft-029',
|
||||
status: 'DRAFT',
|
||||
actor_id: null,
|
||||
created_at: new Date(now - 1000 * 60 * 4).toISOString(),
|
||||
updated_at: new Date(now - 1000 * 60 * 4).toISOString(),
|
||||
instrument_hint: 'Pending review',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function demoPlanState(planId: string): PlanStateDetail {
|
||||
const plan = demoPlans().find((p) => p.plan_id === planId) ?? demoPlans()[0];
|
||||
const base = new Date(plan.created_at).getTime();
|
||||
const mk = (i: number, from: TransactionState | null, to: TransactionState, role: string, actor: string, reason: string) => ({
|
||||
from_state: from,
|
||||
to_state: to,
|
||||
actor_id: actor,
|
||||
actor_role: role,
|
||||
reason,
|
||||
occurred_at: new Date(base + i * 1000 * 60 * 5).toISOString(),
|
||||
});
|
||||
|
||||
if (plan.status === 'COMMITTED') {
|
||||
return {
|
||||
plan_id: plan.plan_id,
|
||||
current_state: 'COMMITTED',
|
||||
transitions: [
|
||||
mk(0, null, 'DRAFT', 'submitter', 'ops.bob', 'plan created'),
|
||||
mk(1, 'DRAFT', 'INITIATED', 'submitter', 'ops.bob', 'initiation'),
|
||||
mk(2, 'INITIATED', 'PRECONDITIONS_PENDING', 'coordinator', 'system', 'await controls'),
|
||||
mk(3, 'PRECONDITIONS_PENDING', 'READY_FOR_PREPARE', 'coordinator', 'system', 'preconditions satisfied'),
|
||||
mk(4, 'READY_FOR_PREPARE', 'PREPARED', 'approver', 'ops.chen', 'approve — prepare (SoD)'),
|
||||
mk(5, 'PREPARED', 'EXECUTING', 'releaser', 'ops.dey', 'release — execute (SoD)'),
|
||||
mk(6, 'EXECUTING', 'VALIDATING', 'coordinator', 'system', 'both legs dispatched'),
|
||||
mk(7, 'VALIDATING', 'COMMITTED', 'validator', 'ops.eve', 'reconciled + committed (SoD)'),
|
||||
],
|
||||
};
|
||||
}
|
||||
if (plan.status === 'ABORTED') {
|
||||
return {
|
||||
plan_id: plan.plan_id,
|
||||
current_state: 'ABORTED',
|
||||
transitions: [
|
||||
mk(0, null, 'DRAFT', 'submitter', 'ops.alice', 'plan created'),
|
||||
mk(1, 'DRAFT', 'INITIATED', 'submitter', 'ops.alice', 'initiation'),
|
||||
mk(2, 'INITIATED', 'PRECONDITIONS_PENDING', 'coordinator', 'system', 'await controls'),
|
||||
mk(3, 'PRECONDITIONS_PENDING', 'READY_FOR_PREPARE', 'coordinator', 'system', 'preconditions satisfied'),
|
||||
mk(4, 'READY_FOR_PREPARE', 'PREPARED', 'approver', 'ops.chen', 'approve — prepare (SoD)'),
|
||||
mk(5, 'PREPARED', 'EXECUTING', 'releaser', 'ops.dey', 'release — execute (SoD)'),
|
||||
mk(6, 'EXECUTING', 'VALIDATING', 'coordinator', 'system', 'both legs dispatched'),
|
||||
mk(7, 'VALIDATING', 'ABORTED', 'validator', 'ops.eve', 'amount mismatch on camt.054'),
|
||||
],
|
||||
};
|
||||
}
|
||||
if (plan.status === 'DRAFT') {
|
||||
return {
|
||||
plan_id: plan.plan_id,
|
||||
current_state: 'DRAFT',
|
||||
transitions: [mk(0, null, 'DRAFT', 'submitter', 'ops.frank', 'plan created')],
|
||||
};
|
||||
}
|
||||
return {
|
||||
plan_id: plan.plan_id,
|
||||
current_state: 'VALIDATING',
|
||||
transitions: [
|
||||
mk(0, null, 'DRAFT', 'submitter', 'ops.alice', 'plan created'),
|
||||
mk(1, 'DRAFT', 'INITIATED', 'submitter', 'ops.alice', 'initiation'),
|
||||
mk(2, 'INITIATED', 'PRECONDITIONS_PENDING', 'coordinator', 'system', 'await controls'),
|
||||
mk(3, 'PRECONDITIONS_PENDING', 'READY_FOR_PREPARE', 'coordinator', 'system', 'preconditions satisfied'),
|
||||
mk(4, 'READY_FOR_PREPARE', 'PREPARED', 'approver', 'ops.chen', 'approve — prepare (SoD)'),
|
||||
mk(5, 'PREPARED', 'EXECUTING', 'releaser', 'ops.dey', 'release — execute (SoD)'),
|
||||
mk(6, 'EXECUTING', 'VALIDATING', 'coordinator', 'system', 'both legs dispatched, awaiting reconciliation'),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function demoPlanEvents(planId: string): PlanEvent[] {
|
||||
const detail = demoPlanState(planId);
|
||||
return detail.transitions.map((t, i) => ({
|
||||
id: i + 1,
|
||||
plan_id: planId,
|
||||
type: `state.${t.to_state.toLowerCase()}`,
|
||||
payload: {
|
||||
from: t.from_state,
|
||||
to: t.to_state,
|
||||
actor_role: t.actor_role,
|
||||
reason: t.reason,
|
||||
},
|
||||
signature: `demo-sig-${i.toString(16).padStart(4, '0')}`,
|
||||
prev_hash: i === 0 ? null : `demo-hash-${(i - 1).toString(16).padStart(4, '0')}`,
|
||||
created_at: t.occurred_at,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user