Compare commits

..

8 Commits

Author SHA1 Message Date
Devin
72ff0e4cc0 Pluggable Rules Engine with JSON DSL
Some checks failed
CI / Frontend Lint (pull_request) Failing after 7s
CI / Frontend Type Check (pull_request) Failing after 6s
CI / Frontend Build (pull_request) Failing after 7s
CI / Frontend E2E Tests (pull_request) Failing after 7s
CI / Orchestrator Build (pull_request) Failing after 7s
CI / Contracts Compile (pull_request) Failing after 6s
CI / Contracts Test (pull_request) Failing after 6s
Code Quality / SonarQube Analysis (pull_request) Failing after 23s
Code Quality / Code Quality Checks (pull_request) Failing after 4s
Security Scan / Dependency Vulnerability Scan (pull_request) Failing after 4s
Security Scan / OWASP ZAP Scan (pull_request) Failing after 3s
Closes gap-analysis v2 §5.2 partial (Rules Engine was hardcoded).

- services/rulesEngine.ts — declarative JSON DSL with a closed
  operator set (eq/neq/gt/gte/lt/lte/in/not_in/exists/matches/
  length_gte/length_lte) + AND/OR/NOT combinators. No eval, no
  runtime code injection. Dotted + indexed path resolver.
- evaluate(ruleSet, context) returns {ok, failures}; 'error'
  severity blocks, 'warn' is reported but non-blocking. 'when'
  clauses gate a rule (e.g. only check compliance.kyc if the
  compliance block is present at all).
- Built-in rule sets mirror the pre-DSL hardcoded checks:
    preconditions.builtin — plan + pay step + participants + KYC
    commit.builtin        — dlt tx hash + bank iso msg id +
                            state=VALIDATING + no exceptions (arch §9.2)
- Pluggable: RULES_FILE env points at a JSON map overriding any
  built-in by id. Silent fall-through to built-ins on error.
- 16 unit tests across operators, combinators, severity semantics,
  'when' gating, built-in rule sets, and loader behaviour.
- Full suite 96/96 green; tsc --noEmit clean.
2026-04-22 18:28:26 +00:00
b66ec0a78f PR G: portal /transactions page + 12-state machine view (#11)
Some checks failed
CI / Frontend Lint (push) Failing after 6s
CI / Frontend Type Check (push) Failing after 6s
CI / Frontend Build (push) Failing after 6s
CI / Frontend E2E Tests (push) Failing after 9s
CI / Orchestrator Build (push) Failing after 6s
CI / Contracts Compile (push) Failing after 6s
CI / Contracts Test (push) Failing after 6s
Security Scan / Dependency Vulnerability Scan (push) Failing after 4s
Security Scan / OWASP ZAP Scan (push) Failing after 4s
2026-04-22 17:18:52 +00:00
3ef71332dc PR F: Idempotency-Key + replay protection on POST /plans and /execute (#10)
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
2026-04-22 17:18:25 +00:00
fd575000fe PR E: SWIFT gateway (MT760, pacs.009, MT202, camt.025/054) (#9)
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
2026-04-22 17:17:51 +00:00
cb376eda31 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
2026-04-22 17:17:40 +00:00
b4d28c77d8 PR B: VALIDATING phase + unified ExceptionManager (arch steps 3, 7) (#6)
Some checks failed
CI / Frontend Lint (push) Failing after 6s
CI / Frontend Type Check (push) Failing after 7s
CI / Frontend Build (push) Has started running
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) Failing after 5s
Security Scan / OWASP ZAP Scan (push) Failing after 5s
2026-04-22 17:15:57 +00:00
84f199fb65 PR A: 12-state transaction machine + issueInstrument step + SoD matrix (#5)
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
2026-04-22 17:15:46 +00:00
c732c1c71a Merge pull request 'feat(portal): wire Solace portal (all 7 pages) to live Chain-138 RPC + SolaceScan Explorer' (#2) from devin/1776532671-solace-bank-portal into main
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
2026-04-22 17:15:28 +00:00
25 changed files with 2706 additions and 5 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();
});
});

View File

@@ -0,0 +1,43 @@
import { query } from "../postgres";
/**
* Migration 003 — append-only events journal (arch §4.5, §5.5, §7).
*
* The `events` table is the system-of-record for normalised workflow
* events (arch §7.2: `transaction.created`, `instrument.ready`,
* `payment.settled`, `transaction.committed`, …). It is:
*
* - append-only (no UPDATE / DELETE)
* - signed (HMAC of (plan_id, type, payload_hash, prev_hash))
* - hash-chained via prev_hash for tamper-evident forensic replay
* - indexed by plan_id so the SSE endpoint can stream efficiently
*/
export async function up() {
await query(
`CREATE TABLE IF NOT EXISTS events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plan_id UUID NOT NULL REFERENCES plans(plan_id) ON DELETE CASCADE,
type VARCHAR(128) NOT NULL,
actor VARCHAR(255),
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
payload_hash CHAR(64) NOT NULL,
prev_hash CHAR(64),
signature CHAR(64) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
);
await query(
`CREATE INDEX IF NOT EXISTS idx_events_plan_id_created
ON events(plan_id, created_at)`,
);
await query(
`CREATE INDEX IF NOT EXISTS idx_events_type
ON events(type)`,
);
}
export async function down() {
await query("DROP TABLE IF EXISTS events CASCADE");
}

View File

@@ -0,0 +1,44 @@
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");
}

View File

@@ -1,5 +1,7 @@
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
@@ -8,6 +10,8 @@ export async function runMigration() {
try {
await up001();
await up002();
await up003();
await up004();
console.log("All migrations completed");
} catch (error) {
console.error("Migration failed:", error);

View File

@@ -9,12 +9,13 @@ import {
requestId,
apiKeyAuth,
auditLog,
idempotencyMiddleware,
} from "./middleware";
import { requestTimeout } from "./middleware/timeout";
import { logger } from "./logging/logger";
import { getMetrics, httpRequestDuration, httpRequestTotal, register } from "./metrics/prometheus";
import { healthCheck, readinessCheck, livenessCheck } from "./health/health";
import { listPlansEndpoint, createPlan, getPlan, getPlanState, addSignature, validatePlanEndpoint } from "./api/plans";
import { listPlansEndpoint, createPlan, getPlan, getPlanState, getPlanEvents, streamPlanEvents, addSignature, validatePlanEndpoint } from "./api/plans";
import { streamPlanStatus } from "./api/sse";
import { executionCoordinator } from "./services/execution";
import { runMigration } from "./db/migrations";
@@ -86,16 +87,18 @@ app.use("/api", apiLimiter);
// Plan management endpoints
app.get("/api/plans", listPlansEndpoint);
app.post("/api/plans", auditLog("CREATE_PLAN", "plan"), createPlan);
app.post("/api/plans", idempotencyMiddleware, 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);
app.get("/api/plans/:planId/events/stream", streamPlanEvents);
app.post("/api/plans/:planId/signature", addSignature);
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", auditLog("EXECUTE_PLAN", "plan"), executePlan);
app.post("/api/plans/:planId/execute", idempotencyMiddleware, 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);

View File

@@ -0,0 +1,120 @@
/**
* 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 };

View File

@@ -5,4 +5,5 @@ export { validate, sanitizeInput } from "./validation";
export { ipWhitelist, getClientIP } from "./ipWhitelist";
export { auditLog } from "./auditLog";
export { sessionManager } from "./session";
export { idempotencyMiddleware, IDEMPOTENCY_HEADER } from "./idempotency";

View File

@@ -0,0 +1,197 @@
/**
* Typed, signed, append-only Event Bus (arch §5.5 Event Bus + §7).
*
* Architecture contract
* ---------------------
* 1. Every event is a normalised category from arch §7.2 — `EventType`.
* 2. Every event is persisted to the `events` append-only table.
* 3. Every event carries
* payload_hash = sha256(JSON.stringify(payload))
* prev_hash = signature of the previous event for the same plan
* signature = hmac_sha256(secret, plan_id|type|payload_hash|prev_hash)
* which gives a tamper-evident per-plan hash chain (arch §14 audit).
* 4. Callers can subscribe to live events via `subscribe(planId, cb)` —
* backed by a process-local EventEmitter that the SSE route consumes.
*
* When the orchestrator scales to >1 replicas, the in-process emitter
* must be replaced by a broker (NATS / Kafka). The persistence layer
* and signature chain remain unchanged.
*/
import { createHash, createHmac } from "crypto";
import { EventEmitter } from "events";
import { query } from "../db/postgres";
import { logger } from "../logging/logger";
/**
* Normalised event types — arch §7.2. Keep this list as the single
* source of truth so subscribers can exhaustively match on it.
*/
export const EVENT_TYPES = [
"transaction.created",
"participants.authorized",
"preconditions.satisfied",
"instrument.ready",
"payment.ready",
"transaction.prepared",
"instrument.dispatched",
"payment.dispatched",
"instrument.acknowledged",
"payment.accepted",
"payment.settled",
"transaction.validated",
"transaction.committed",
"transaction.aborted",
"transaction.unwind_initiated",
] as const;
export type EventType = (typeof EVENT_TYPES)[number];
export interface EventRecord {
id: string;
plan_id: string;
type: EventType;
actor: string | null;
payload: Record<string, unknown>;
payload_hash: string;
prev_hash: string | null;
signature: string;
created_at: string;
}
export interface PublishInput {
planId: string;
type: EventType;
actor?: string;
payload?: Record<string, unknown>;
}
const emitter = new EventEmitter();
emitter.setMaxListeners(0);
function getSigningSecret(): string {
return (
process.env.EVENT_BUS_HMAC_SECRET ??
process.env.SESSION_SECRET ??
"dev-event-bus-secret-change-in-production"
);
}
function sha256(input: string): string {
return createHash("sha256").update(input).digest("hex");
}
function sign(
planId: string,
type: string,
payloadHash: string,
prevHash: string | null,
): string {
const h = createHmac("sha256", getSigningSecret());
h.update(`${planId}|${type}|${payloadHash}|${prevHash ?? ""}`);
return h.digest("hex");
}
/**
* Publish a typed, signed, hash-chained event for a plan. Returns the
* persisted record (including id + signature) so callers can reference
* it from transition `source_event_id`.
*/
export async function publish(input: PublishInput): Promise<EventRecord> {
const payload = input.payload ?? {};
const payloadHash = sha256(JSON.stringify(payload));
const prev = await query<{ signature: string }>(
`SELECT signature
FROM events
WHERE plan_id = $1
ORDER BY created_at DESC, id DESC
LIMIT 1`,
[input.planId],
);
const prevHash = prev.length > 0 ? prev[0].signature : null;
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)
VALUES ($1, $2, $3, $4::jsonb, $5, $6, $7)
RETURNING id, plan_id, type, actor, payload, payload_hash, prev_hash, signature, created_at`,
[
input.planId,
input.type,
input.actor ?? null,
JSON.stringify(payload),
payloadHash,
prevHash,
signature,
],
);
const record = rows[0];
logger.info(
{ planId: record.plan_id, type: record.type, eventId: record.id },
"[EventBus] published",
);
emitter.emit(`plan:${record.plan_id}`, record);
emitter.emit("plan:*", record);
return record;
}
/**
* Read the full event trail for a plan in chronological order.
*/
export async function getEventsForPlan(planId: string): Promise<EventRecord[]> {
return query<EventRecord>(
`SELECT id, plan_id, type, actor, payload, payload_hash, prev_hash, signature, created_at
FROM events
WHERE plan_id = $1
ORDER BY created_at ASC, id ASC`,
[planId],
);
}
/**
* Verify the full hash chain for a plan's events. Returns `{ ok: true }`
* when every signature matches and `prev_hash` forms a contiguous chain;
* otherwise returns the first index that fails with a reason.
*/
export async function verifyChain(planId: string): Promise<
{ ok: true } | { ok: false; brokenAt: number; reason: string }
> {
const events = await getEventsForPlan(planId);
let prevSig: string | null = null;
for (let i = 0; i < events.length; i++) {
const e = events[i];
if (e.prev_hash !== prevSig) {
return { ok: false, brokenAt: i, reason: "prev_hash mismatch" };
}
const expectedPayloadHash = sha256(JSON.stringify(e.payload));
if (expectedPayloadHash !== e.payload_hash) {
return { ok: false, brokenAt: i, reason: "payload_hash mismatch" };
}
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;
}
return { ok: true };
}
/**
* Subscribe to live events for a single plan. Returns an unsubscribe
* function. Used by the SSE route.
*/
export function subscribe(
planId: string,
callback: (record: EventRecord) => void,
): () => void {
const channel = `plan:${planId}`;
emitter.on(channel, callback);
return () => emitter.off(channel, callback);
}
/** test-only emitter access, never import in prod code */
export const __emitterForTests = emitter;

View File

@@ -0,0 +1,304 @@
/**
* Pluggable Rules Engine (arch §5.2 Rules Engine; gap v2 §5.2 partial).
*
* Before this PR, business rules were hardcoded at the call sites
* (e.g. "plan must have a pay step" baked into iso20022.ts, SoD
* matrix hard-coded in transactionState.ts). This module introduces
* a minimal, declarative JSON DSL so that ruleSets can be loaded
* from env (RULES_FILE) or swapped per-environment.
*
* Design principles
* -----------------
* - No eval. The evaluator is a small recursive switch over a
* closed operator set — no runtime code injection.
* - Pure, deterministic, side-effect free. Evaluation order is
* explicit so the engine can be reasoned about and replayed.
* - Context is a flat name → value map. Callers project whatever
* shape they need ({plan, state, compliance, participants}).
* - Failures are collected, not thrown. The caller decides whether
* a single failure aborts, or whether to accumulate and report.
*/
import { readFileSync } from "fs";
/** Supported primitive operators. */
export type Operator =
| "eq"
| "neq"
| "gt"
| "gte"
| "lt"
| "lte"
| "in"
| "not_in"
| "exists"
| "matches" // regex
| "length_gte"
| "length_lte";
/** Leaf condition — references a context path against a literal. */
export interface LeafCondition {
path: string; // dotted path into the context object
op: Operator;
value?: unknown; // not required for `exists`
/** Optional human label for failure messages. */
message?: string;
}
/** Combinator — AND / OR / NOT over child conditions. */
export interface AndCondition {
all: Condition[];
message?: string;
}
export interface OrCondition {
any: Condition[];
message?: string;
}
export interface NotCondition {
not: Condition;
message?: string;
}
export type Condition = LeafCondition | AndCondition | OrCondition | NotCondition;
export interface Rule {
id: string;
description?: string;
when?: Condition; // precondition — rule only fires when `when` is true
assert: Condition; // the rule passes when `assert` evaluates true
/** Optional severity for reporting: "error" (default) blocks, "warn" does not. */
severity?: "error" | "warn";
}
export interface RuleSet {
id: string;
version?: string;
rules: Rule[];
}
export interface RuleFailure {
ruleId: string;
severity: "error" | "warn";
message: string;
path?: string;
}
export interface EvaluationResult {
ok: boolean;
failures: RuleFailure[];
}
/* -----------------------------------------------------------------
* Dotted-path resolver. Supports a.b.c and a.b[0].c.
* --------------------------------------------------------------- */
function getPath(ctx: unknown, path: string): unknown {
if (!path) return ctx;
const parts = path
.replace(/\[(\d+)\]/g, ".$1")
.split(".")
.filter(Boolean);
let cur: unknown = ctx;
for (const p of parts) {
if (cur === null || cur === undefined) return undefined;
if (typeof cur === "object") {
cur = (cur as Record<string, unknown>)[p];
} else {
return undefined;
}
}
return cur;
}
/* -----------------------------------------------------------------
* Operator evaluation. Pure — no throws.
* --------------------------------------------------------------- */
function evalOp(op: Operator, actual: unknown, expected: unknown): boolean {
switch (op) {
case "eq":
return actual === expected;
case "neq":
return actual !== expected;
case "gt":
return typeof actual === "number" && typeof expected === "number" && actual > expected;
case "gte":
return typeof actual === "number" && typeof expected === "number" && actual >= expected;
case "lt":
return typeof actual === "number" && typeof expected === "number" && actual < expected;
case "lte":
return typeof actual === "number" && typeof expected === "number" && actual <= expected;
case "in":
return Array.isArray(expected) && expected.includes(actual as never);
case "not_in":
return Array.isArray(expected) && !expected.includes(actual as never);
case "exists":
return actual !== undefined && actual !== null;
case "matches":
if (typeof actual !== "string" || typeof expected !== "string") return false;
try {
return new RegExp(expected).test(actual);
} catch {
return false;
}
case "length_gte":
if (!Array.isArray(actual) && typeof actual !== "string") return false;
return (actual as { length: number }).length >= (expected as number);
case "length_lte":
if (!Array.isArray(actual) && typeof actual !== "string") return false;
return (actual as { length: number }).length <= (expected as number);
default:
return false;
}
}
function isLeaf(c: Condition): c is LeafCondition {
return (c as LeafCondition).op !== undefined && (c as LeafCondition).path !== undefined;
}
export function evaluateCondition(
condition: Condition,
context: Record<string, unknown>,
): boolean {
if (isLeaf(condition)) {
const actual = getPath(context, condition.path);
return evalOp(condition.op, actual, condition.value);
}
if ("all" in condition) {
return condition.all.every((c) => evaluateCondition(c, context));
}
if ("any" in condition) {
return condition.any.some((c) => evaluateCondition(c, context));
}
if ("not" in condition) {
return !evaluateCondition(condition.not, context);
}
return false;
}
/* -----------------------------------------------------------------
* Public evaluate(): runs the full rule set and collects failures.
* --------------------------------------------------------------- */
export function evaluate(
ruleSet: RuleSet,
context: Record<string, unknown>,
): EvaluationResult {
const failures: RuleFailure[] = [];
for (const rule of ruleSet.rules) {
if (rule.when && !evaluateCondition(rule.when, context)) continue;
const passed = evaluateCondition(rule.assert, context);
if (!passed) {
failures.push({
ruleId: rule.id,
severity: rule.severity ?? "error",
message: rule.description ?? `rule ${rule.id} failed`,
path: isLeaf(rule.assert) ? rule.assert.path : undefined,
});
}
}
const blocking = failures.filter((f) => f.severity === "error");
return { ok: blocking.length === 0, failures };
}
/* -----------------------------------------------------------------
* Built-in rule sets. These mirror the pre-DSL hardcoded checks so
* callers can migrate incrementally.
* --------------------------------------------------------------- */
/** Preconditions check — arch §8 PRECONDITIONS_PENDING -> READY_FOR_PREPARE. */
export const BUILTIN_PRECONDITIONS: RuleSet = {
id: "preconditions.builtin",
version: "1",
rules: [
{
id: "plan.exists",
description: "plan must be present on the context",
assert: { path: "plan", op: "exists" },
},
{
id: "plan.steps.non_empty",
description: "plan must contain at least one step",
assert: { path: "plan.steps", op: "length_gte", value: 1 },
},
{
id: "plan.pay_step_present",
description: "plan must contain at least one pay step (ISO-20022 envelope)",
assert: {
any: [
{ path: "plan.steps[0].type", op: "eq", value: "pay" },
{ path: "plan.steps[1].type", op: "eq", value: "pay" },
{ path: "plan.steps[2].type", op: "eq", value: "pay" },
{ path: "plan.steps[3].type", op: "eq", value: "pay" },
],
},
},
{
id: "participants.at_least_one",
description: "participant registry must not be empty",
assert: { path: "participants", op: "length_gte", value: 1 },
},
{
id: "compliance.kyc_ok",
description: "compliance KYC status must be ok",
when: { path: "compliance", op: "exists" },
assert: { path: "compliance.kyc", op: "eq", value: "ok" },
},
],
};
/** Commit rule — arch §9.2. */
export const BUILTIN_COMMIT: RuleSet = {
id: "commit.builtin",
version: "1",
rules: [
{
id: "dlt.tx_hash",
description: "DLT leg must produce a 0x + 64-hex tx hash",
assert: { path: "dlt.txHash", op: "matches", value: "^0x[0-9a-fA-F]{64}$" },
},
{
id: "bank.iso_message_id",
description: "bank leg must produce a non-empty ISO message id",
assert: { path: "bank.isoMessageId", op: "exists" },
},
{
id: "state.is_validating",
description: "commit is only valid from VALIDATING",
assert: { path: "state", op: "eq", value: "VALIDATING" },
},
{
id: "no_exception_holds",
description: "no exception may be outstanding",
assert: { path: "exceptions.active", op: "length_lte", value: 0 },
},
],
};
/* -----------------------------------------------------------------
* Loader: RULES_FILE env points at a JSON file containing a map
* {ruleSetId: RuleSet}. Falls back to built-ins on any error.
* --------------------------------------------------------------- */
let cachedOverrides: Record<string, RuleSet> | undefined;
export function getRuleSet(id: string): RuleSet {
if (cachedOverrides === undefined) {
cachedOverrides = {};
const path = process.env.RULES_FILE;
if (path) {
try {
const raw = readFileSync(path, "utf8");
const parsed = JSON.parse(raw) as Record<string, RuleSet>;
if (parsed && typeof parsed === "object") cachedOverrides = parsed;
} catch {
// leave empty — silent fall-through to built-ins
}
}
}
if (cachedOverrides[id]) return cachedOverrides[id];
if (id === BUILTIN_PRECONDITIONS.id) return BUILTIN_PRECONDITIONS;
if (id === BUILTIN_COMMIT.id) return BUILTIN_COMMIT;
return { id, rules: [] };
}
export function __resetRulesCacheForTests(): void {
cachedOverrides = undefined;
}

View File

@@ -0,0 +1,129 @@
/**
* camt.025 (Receipt) and camt.054 (Bank-to-Customer Debit/Credit
* Notification) ingestion.
*
* Arch §4.3 + §9.2. These are the inbound settlement-confirmation
* messages that allow the VALIDATING phase to mark the payment leg
* as SETTLED. The parser is intentionally minimal — just enough to
* extract the fields the VALIDATING reconciliation compares against.
*/
export interface Camt025Receipt {
type: "camt.025";
messageId: string;
originalMessageId: string;
status: "ACCP" | "ACSC" | "ACSP" | "RJCT" | "PDNG" | string;
reasonCode?: string;
dateTime?: string;
}
export interface Camt054Notification {
type: "camt.054";
messageId: string;
creditDebitIndicator: "CRDT" | "DBIT";
amount: number;
currency: string;
endToEndId?: string;
valueDate?: string;
bookingDate?: string;
}
export type CamtMessage = Camt025Receipt | Camt054Notification;
function extractTag(xml: string, tag: string): string | undefined {
const re = new RegExp(`<${tag}[^>]*>([^<]*)</${tag}>`);
const m = re.exec(xml);
return m ? m[1].trim() : undefined;
}
function extractAmountWithCcy(xml: string, tag: string): { amount: number; currency: string } | undefined {
const re = new RegExp(`<${tag}[^>]*Ccy="([A-Z]{3})"[^>]*>([^<]*)</${tag}>`);
const m = re.exec(xml);
return m ? { currency: m[1], amount: Number(m[2]) } : undefined;
}
/**
* Parse a camt.025 Receipt. Only fields used by the orchestrator are
* surfaced; everything else stays in the raw XML.
*/
export function parseCamt025(xml: string): Camt025Receipt {
if (!/xmlns="urn:iso:std:iso:20022:tech:xsd:camt\.025/.test(xml)) {
throw new Error("camt.025: xmlns marker not found");
}
const messageId = extractTag(xml, "MsgId") ?? "";
const originalMessageId = extractTag(xml, "OrgnlMsgId") ?? "";
const status = (extractTag(xml, "Cd") ?? extractTag(xml, "ConfSts") ?? "PDNG") as Camt025Receipt["status"];
const reasonCode = extractTag(xml, "PrtryStsRsn") ?? extractTag(xml, "Rsn");
const dateTime = extractTag(xml, "CreDtTm");
if (!messageId) throw new Error("camt.025: missing MsgId");
if (!originalMessageId) throw new Error("camt.025: missing OrgnlMsgId");
return { type: "camt.025", messageId, originalMessageId, status, reasonCode, dateTime };
}
/**
* Parse a camt.054 Credit/Debit Notification.
*/
export function parseCamt054(xml: string): Camt054Notification {
if (!/xmlns="urn:iso:std:iso:20022:tech:xsd:camt\.054/.test(xml)) {
throw new Error("camt.054: xmlns marker not found");
}
const messageId = extractTag(xml, "MsgId") ?? "";
const cdtDbt = (extractTag(xml, "CdtDbtInd") ?? "CRDT") as "CRDT" | "DBIT";
const amt = extractAmountWithCcy(xml, "Amt");
if (!amt) throw new Error("camt.054: missing Amt");
const endToEndId = extractTag(xml, "EndToEndId");
const valueDate = extractTag(xml, "ValDt");
const bookingDate = extractTag(xml, "BookgDt");
if (!messageId) throw new Error("camt.054: missing MsgId");
return {
type: "camt.054",
messageId,
creditDebitIndicator: cdtDbt,
amount: amt.amount,
currency: amt.currency,
endToEndId,
valueDate,
bookingDate,
};
}
/**
* Dispatch on the xmlns marker. Throws if the document is neither
* camt.025 nor camt.054.
*/
export function parseCamt(xml: string): CamtMessage {
if (/xmlns="urn:iso:std:iso:20022:tech:xsd:camt\.025/.test(xml)) return parseCamt025(xml);
if (/xmlns="urn:iso:std:iso:20022:tech:xsd:camt\.054/.test(xml)) return parseCamt054(xml);
throw new Error("camt: unsupported or missing xmlns (expected camt.025 or camt.054)");
}
/**
* Reconcile a camt.054 credit notification against an expected
* (amount, currency, endToEndId). Returns the list of mismatches so
* VALIDATING can feed them into Data.valueMismatch().
*/
export interface ReconcileExpected {
amount: number;
currency: string;
endToEndId?: string;
}
export function reconcileCamt054(
msg: Camt054Notification,
expected: ReconcileExpected,
): Array<{ field: string; expected: unknown; actual: unknown }> {
const mismatches: Array<{ field: string; expected: unknown; actual: unknown }> = [];
if (msg.creditDebitIndicator !== "CRDT") {
mismatches.push({ field: "creditDebitIndicator", expected: "CRDT", actual: msg.creditDebitIndicator });
}
if (msg.currency !== expected.currency) {
mismatches.push({ field: "currency", expected: expected.currency, actual: msg.currency });
}
if (msg.amount !== expected.amount) {
mismatches.push({ field: "amount", expected: expected.amount, actual: msg.amount });
}
if (expected.endToEndId && msg.endToEndId && msg.endToEndId !== expected.endToEndId) {
mismatches.push({ field: "endToEndId", expected: expected.endToEndId, actual: msg.endToEndId });
}
return mismatches;
}

View File

@@ -0,0 +1,36 @@
/**
* SWIFT gateway — public surface (arch §4.2 + §4.3).
*
* Outbound generators:
* - generateMt760 : issuance of SBLC (Cat-7 FIN)
* - generatePacs009 : FI-to-FI credit transfer (ISO 20022 XML)
* - generateMt202 : FIN equivalent of pacs.009 for non-migrated
* corridors
*
* Inbound parsers:
* - parseCamt025 : receipt / status of a prior instruction
* - parseCamt054 : bank-to-customer credit/debit notification
* - reconcileCamt054: diff a camt.054 against the expected amount,
* currency, and end-to-end id
*
* Channel selection (arch §9.2 accepted !== settled):
* - pacs.008 remains the customer-initiated PSP channel (existing
* `services/iso20022.ts`). COMMIT must not fire on pacs.008
* "acceptance" alone.
* - pacs.009 / MT202 is the interbank settlement channel; COMMIT
* requires either camt.025 ACSC or camt.054 CRDT evidence here.
*/
export { generateMt760, messageHash, type Mt760Message } from "./mt760";
export { generatePacs009, type Pacs009Options, type Pacs009Result } from "./pacs009";
export { generateMt202, type Mt202Options, type Mt202Message } from "./mt202";
export {
parseCamt,
parseCamt025,
parseCamt054,
reconcileCamt054,
type Camt025Receipt,
type Camt054Notification,
type CamtMessage,
type ReconcileExpected,
} from "./camt";

View File

@@ -0,0 +1,78 @@
/**
* MT202 COV — General Financial Institution Transfer (cover method).
*
* Arch §4.3. FIN equivalent of pacs.009 used on SWIFT networks that
* have not yet migrated to ISO 20022. Generated alongside pacs.009
* during transitional period — settlement confirmation can arrive on
* either channel.
*/
import type { Plan, PlanStep } from "../../types/plan";
export interface Mt202Options {
transactionReference: string;
relatedReference?: string;
valueDate: string; // YYYY-MM-DD
sendingInstitution: string; // BIC
receivingInstitution: string;// BIC
beneficiaryInstitution: string; // BIC
orderingInstitution?: string;// BIC
}
export interface Mt202Message {
sender: string;
receiver: string;
fin: string;
fields: Record<string, string>;
}
function yyMMdd(iso: string): string {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
if (!m) throw new Error(`MT202: valueDate must be YYYY-MM-DD, got '${iso}'`);
return `${m[1].slice(2)}${m[2]}${m[3]}`;
}
function bicCheck(bic: string, field: string): void {
if (!/^[A-Z0-9]{8}([A-Z0-9]{3})?$/.test(bic)) {
throw new Error(`MT202: ${field} must be a valid BIC, got '${bic}'`);
}
}
function findPayStep(plan: Plan): PlanStep {
const step = plan.steps.find((s) => s.type === "pay");
if (!step) throw new Error("MT202: plan must contain a 'pay' step");
return step;
}
export function generateMt202(plan: Plan, opts: Mt202Options): Mt202Message {
bicCheck(opts.sendingInstitution, "sendingInstitution");
bicCheck(opts.receivingInstitution, "receivingInstitution");
bicCheck(opts.beneficiaryInstitution, "beneficiaryInstitution");
if (opts.orderingInstitution) bicCheck(opts.orderingInstitution, "orderingInstitution");
const payStep = findPayStep(plan);
const ccy = (payStep.asset ?? "USD").toUpperCase();
const amount = payStep.amount.toFixed(2).replace(".", ",");
const field32A = `${yyMMdd(opts.valueDate)}${ccy}${amount}`;
const fields: Record<string, string> = {
"20": opts.transactionReference,
"21": opts.relatedReference ?? opts.transactionReference,
"32A": field32A,
"52A": opts.orderingInstitution ?? opts.sendingInstitution,
"57A": opts.receivingInstitution,
"58A": opts.beneficiaryInstitution,
};
const block1 = `{1:F01${opts.sendingInstitution.padEnd(12, "X")}0000000000}`;
const block2 = `{2:I202${opts.receivingInstitution.padEnd(12, "X")}N}`;
const block4 = Object.entries(fields).map(([t, v]) => `:${t}:${v}`).join("\n");
const block4Wrapped = `{4:\n${block4}\n-}`;
return {
sender: opts.sendingInstitution,
receiver: opts.receivingInstitution,
fin: `${block1}${block2}${block4Wrapped}`,
fields,
};
}

View File

@@ -0,0 +1,112 @@
/**
* MT760 — Issue of a Demand Guarantee / Standby Letter of Credit
* (arch §4.2 Banking Instrument Layer + §6 Instrument Terms Hash).
*
* SWIFT FIN message. This is the issuance leg of the two-phase
* commit. Output is deterministic so the planHash anchored on-chain
* can be reproduced by any party with access to the InstrumentTerms.
*
* Reference: SWIFT FIN Category 7 User Handbook, MT760 format;
* Emirates Islamic Bank beneficiary-format SBLC template.
*/
import { createHash } from "crypto";
import type { InstrumentTerms } from "../../types/plan";
export interface Mt760Message {
sender: string;
receiver: string;
messageReference: string;
fin: string;
fields: Record<string, string>;
}
function formatAmount(amount: number, currency: string): string {
// SWIFT FIN amount: 3-letter currency + 15n,2d (max), decimal comma.
if (amount < 0) throw new Error("MT760: amount must be non-negative");
return `${currency}${amount.toFixed(2).replace(".", ",")}`;
}
function yyMMdd(iso: string): string {
// Accept YYYY-MM-DD and return YYMMDD.
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
if (!m) throw new Error(`MT760: expiryDate must be YYYY-MM-DD, got '${iso}'`);
return `${m[1].slice(2)}${m[2]}${m[3]}`;
}
/**
* Render an MT760 from an InstrumentTerms record. Uses the
* block-structured FIN format (Block 1/2/4/5). Tag codes:
*
* :20: Transaction reference number
* :23: Further identification
* :27: Sequence of total (here: 1/1)
* :30: Date of issue
* :40C: Applicable rules (URDG 758, UCP 600)
* :31D: Date and place of expiry
* :50: Applicant
* :52A: Issuing bank (BIC)
* :59: Beneficiary name + account
* :32B: Amount
* :77C: Details of guarantee
* :72Z: Sender to receiver info
*/
export function generateMt760(
terms: InstrumentTerms,
opts: { transactionReference: string; issueDate: string },
): Mt760Message {
const sender = terms.issuingBankBIC;
const receiver = terms.beneficiaryBankBIC;
const field32B = formatAmount(terms.amount, terms.currency);
const field31D = `${yyMMdd(terms.expiryDate)}${terms.placeOfPresentation.toUpperCase()}`;
const fields: Record<string, string> = {
"20": opts.transactionReference,
"23": "ISSUE OF STANDBY LETTER OF CREDIT",
"27": "1/1",
"30": yyMMdd(opts.issueDate),
"40C": terms.governingLaw,
"31D": field31D,
"50": terms.applicant,
"52A": terms.issuingBankBIC,
"59": [terms.beneficiaryName, terms.beneficiaryAccount].filter(Boolean).join("\n"),
"32B": field32B,
"77C": [
`TEMPLATE/${terms.templateRef}`,
`TEMPLATE_HASH/${terms.templateHash}`,
`TENOR/${terms.tenor}`,
].join("\n"),
"72Z": `GOVLAW/${terms.governingLaw}`,
};
// Build FIN block 4 body with :tag:value sequences.
const block4 = Object.entries(fields)
.map(([tag, value]) => `:${tag}:${value}`)
.join("\n");
const block1 = `{1:F01${sender.padEnd(12, "X")}0000000000}`;
const block2 = `{2:I760${receiver.padEnd(12, "X")}N}`;
const block4Wrapped = `{4:\n${block4}\n-}`;
const block5 = `{5:{CHK:${checksum(block4)}}}`;
const fin = `${block1}${block2}${block4Wrapped}${block5}`;
return { sender, receiver, messageReference: opts.transactionReference, fin, fields };
}
/**
* Deterministic SHA-256 over the canonical field list. Matches
* InstrumentTerms.templateHash when all 11 required fields are filled
* in with the SBLC template values.
*/
export function messageHash(msg: Mt760Message): string {
const canonical = Object.entries(msg.fields)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}=${v}`)
.join("\n");
return createHash("sha256").update(canonical).digest("hex");
}
function checksum(block4Body: string): string {
return createHash("sha256").update(block4Body).digest("hex").slice(0, 12).toUpperCase();
}

View File

@@ -0,0 +1,94 @@
/**
* pacs.009 — Financial Institution Credit Transfer (ISO 20022).
*
* Arch §4.3 Payment Messaging / Settlement Layer. Used for
* **bank-to-bank** credit transfers (the interbank leg); pacs.008 is
* for **customer-to-bank** PSP-initiated transfers. The gap-analysis
* flagged that ExecutionCoordinator was generating pacs.008 for what
* is actually a FI-to-FI settlement leg — this module fixes that.
*
* Reference: ISO 20022 Payments Maintenance 2019 / 2022,
* pacs.009.001.08 schema.
*/
import type { Plan, PlanStep } from "../../types/plan";
export interface Pacs009Options {
messageId: string;
creationDateTime?: string;
instructingAgentBIC: string;
instructedAgentBIC: string;
debtorAgentBIC: string;
creditorAgentBIC: string;
endToEndId?: string;
}
export interface Pacs009Result {
messageId: string;
endToEndId: string;
xml: string;
}
function bicCheck(bic: string, field: string): void {
if (!/^[A-Z0-9]{8}([A-Z0-9]{3})?$/.test(bic)) {
throw new Error(`pacs.009: ${field} must be a valid BIC, got '${bic}'`);
}
}
function findPayStep(plan: Plan): PlanStep {
const step = plan.steps.find((s) => s.type === "pay");
if (!step) throw new Error("pacs.009: plan must contain a 'pay' step");
return step;
}
/**
* Render a pacs.009.001.08 XML message for the interbank leg of the
* plan's `pay` step.
*/
export function generatePacs009(plan: Plan, opts: Pacs009Options): Pacs009Result {
bicCheck(opts.instructingAgentBIC, "instructingAgentBIC");
bicCheck(opts.instructedAgentBIC, "instructedAgentBIC");
bicCheck(opts.debtorAgentBIC, "debtorAgentBIC");
bicCheck(opts.creditorAgentBIC, "creditorAgentBIC");
const payStep = findPayStep(plan);
const messageId = opts.messageId;
const endToEndId = opts.endToEndId ?? `E2E-${plan.plan_id ?? messageId}`;
const creDtTm = opts.creationDateTime ?? new Date().toISOString();
const ccy = (payStep.asset ?? "USD").toUpperCase();
const amount = payStep.amount.toFixed(2);
const settleDate = creDtTm.split("T")[0];
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pacs.009.001.08">
<FICdtTrf>
<GrpHdr>
<MsgId>${escapeXml(messageId)}</MsgId>
<CreDtTm>${escapeXml(creDtTm)}</CreDtTm>
<NbOfTxs>1</NbOfTxs>
<SttlmInf><SttlmMtd>INGA</SttlmMtd></SttlmInf>
<InstgAgt><FinInstnId><BICFI>${opts.instructingAgentBIC}</BICFI></FinInstnId></InstgAgt>
<InstdAgt><FinInstnId><BICFI>${opts.instructedAgentBIC}</BICFI></FinInstnId></InstdAgt>
</GrpHdr>
<CdtTrfTxInf>
<PmtId>
<InstrId>${escapeXml(messageId)}</InstrId>
<EndToEndId>${escapeXml(endToEndId)}</EndToEndId>
<TxId>${escapeXml(messageId)}</TxId>
</PmtId>
<IntrBkSttlmAmt Ccy="${ccy}">${amount}</IntrBkSttlmAmt>
<IntrBkSttlmDt>${settleDate}</IntrBkSttlmDt>
<Dbtr><FinInstnId><BICFI>${opts.debtorAgentBIC}</BICFI></FinInstnId></Dbtr>
<DbtrAgt><FinInstnId><BICFI>${opts.debtorAgentBIC}</BICFI></FinInstnId></DbtrAgt>
<CdtrAgt><FinInstnId><BICFI>${opts.creditorAgentBIC}</BICFI></FinInstnId></CdtrAgt>
<Cdtr><FinInstnId><BICFI>${opts.creditorAgentBIC}</BICFI></FinInstnId></Cdtr>
</CdtTrfTxInf>
</FICdtTrf>
</Document>`;
return { messageId, endToEndId, xml };
}
function escapeXml(s: string): string {
return s.replace(/[<>&"']/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", '"': "&quot;", "'": "&apos;" }[c]!));
}

View File

@@ -0,0 +1,149 @@
import { describe, it, expect, beforeEach, jest } from "@jest/globals";
type Row = {
id: string;
plan_id: string;
type: string;
actor: string | null;
payload: Record<string, unknown>;
payload_hash: string;
prev_hash: string | null;
signature: string;
created_at: string;
};
const rows: Row[] = [];
let idSeq = 0;
jest.mock("../../src/db/postgres", () => ({
query: async (sql: string, params: unknown[] = []) => {
if (sql.startsWith("SELECT signature")) {
const planId = params[0] as string;
const matches = rows.filter((r) => r.plan_id === planId);
if (matches.length === 0) return [];
return [{ signature: matches[matches.length - 1].signature }];
}
if (sql.startsWith("INSERT INTO events")) {
const [plan_id, type, actor, payloadJson, payload_hash, prev_hash, signature] =
params as [string, string, string | null, string, string, string | null, string];
const rec: Row = {
id: `evt-${++idSeq}`,
plan_id,
type,
actor,
payload: JSON.parse(payloadJson),
payload_hash,
prev_hash,
signature,
created_at: new Date(Date.now() + idSeq).toISOString(),
};
rows.push(rec);
return [rec];
}
if (sql.startsWith("SELECT id, plan_id")) {
const planId = params[0] as string;
return rows.filter((r) => r.plan_id === planId);
}
return [];
},
}));
import { publish, getEventsForPlan, verifyChain, EVENT_TYPES } from "../../src/services/eventBus";
describe("Event Bus", () => {
beforeEach(() => {
rows.length = 0;
idSeq = 0;
});
it("EVENT_TYPES covers all arch §7.2 categories", () => {
expect(EVENT_TYPES).toContain("transaction.created");
expect(EVENT_TYPES).toContain("transaction.committed");
expect(EVENT_TYPES).toContain("transaction.aborted");
expect(EVENT_TYPES).toContain("payment.settled");
expect(EVENT_TYPES).toContain("instrument.dispatched");
expect(EVENT_TYPES.length).toBe(15);
});
it("publish persists with payload_hash, prev_hash=null, and signature", async () => {
const rec = await publish({
planId: "p-1",
type: "transaction.created",
actor: "coordinator",
payload: { foo: 1 },
});
expect(rec.id).toMatch(/evt-/);
expect(rec.prev_hash).toBeNull();
expect(rec.payload_hash).toMatch(/^[0-9a-f]{64}$/);
expect(rec.signature).toMatch(/^[0-9a-f]{64}$/);
expect(rec.payload).toEqual({ foo: 1 });
});
it("prev_hash chains consecutive events for the same plan", async () => {
const a = await publish({ planId: "p-1", type: "transaction.created" });
const b = await publish({ planId: "p-1", type: "participants.authorized" });
const c = await publish({ planId: "p-1", type: "preconditions.satisfied" });
expect(a.prev_hash).toBeNull();
expect(b.prev_hash).toBe(a.signature);
expect(c.prev_hash).toBe(b.signature);
});
it("events are isolated per plan_id", async () => {
const a1 = await publish({ planId: "p-1", type: "transaction.created" });
const b1 = await publish({ planId: "p-2", type: "transaction.created" });
expect(a1.prev_hash).toBeNull();
expect(b1.prev_hash).toBeNull();
});
it("verifyChain returns ok for an untampered chain", async () => {
await publish({ planId: "p-1", type: "transaction.created" });
await publish({ planId: "p-1", type: "transaction.prepared" });
await publish({ planId: "p-1", type: "transaction.committed" });
const result = await verifyChain("p-1");
expect(result.ok).toBe(true);
});
it("verifyChain detects payload tampering", async () => {
await publish({ planId: "p-1", type: "transaction.created", payload: { amount: 100 } });
await publish({ planId: "p-1", type: "transaction.committed" });
rows[0].payload = { amount: 999_999 }; // tamper
const result = await verifyChain("p-1");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.brokenAt).toBe(0);
expect(result.reason).toBe("payload_hash mismatch");
}
});
it("verifyChain detects signature tampering", async () => {
await publish({ planId: "p-1", type: "transaction.created" });
await publish({ planId: "p-1", type: "transaction.committed" });
rows[1].signature = "0".repeat(64); // tamper
const result = await verifyChain("p-1");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.brokenAt).toBe(1);
}
});
it("verifyChain detects broken prev_hash link", async () => {
await publish({ planId: "p-1", type: "transaction.created" });
await publish({ planId: "p-1", type: "transaction.committed" });
rows[1].prev_hash = "0".repeat(64);
const result = await verifyChain("p-1");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("prev_hash mismatch");
}
});
it("getEventsForPlan returns events in chronological order", async () => {
await publish({ planId: "p-1", type: "transaction.created" });
await publish({ planId: "p-1", type: "transaction.prepared" });
const events = await getEventsForPlan("p-1");
expect(events.map((e) => e.type)).toEqual([
"transaction.created",
"transaction.prepared",
]);
});
});

View File

@@ -0,0 +1,177 @@
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);
});
});

View File

@@ -0,0 +1,245 @@
/**
* PR P — Pluggable Rules Engine (gap-analysis v2 §5.2 partial).
*/
import { describe, it, expect, beforeEach } from "@jest/globals";
import {
evaluate,
evaluateCondition,
getRuleSet,
BUILTIN_PRECONDITIONS,
BUILTIN_COMMIT,
__resetRulesCacheForTests,
type RuleSet,
} from "../../src/services/rulesEngine";
describe("rulesEngine — primitive operators", () => {
it("eq / neq / gt / gte / lt / lte", () => {
expect(
evaluateCondition({ path: "a", op: "eq", value: 1 }, { a: 1 }),
).toBe(true);
expect(
evaluateCondition({ path: "a", op: "neq", value: 1 }, { a: 2 }),
).toBe(true);
expect(
evaluateCondition({ path: "a", op: "gt", value: 1 }, { a: 2 }),
).toBe(true);
expect(
evaluateCondition({ path: "a", op: "lte", value: 3 }, { a: 3 }),
).toBe(true);
});
it("in / not_in / exists / matches", () => {
expect(
evaluateCondition(
{ path: "role", op: "in", value: ["approver", "releaser"] },
{ role: "approver" },
),
).toBe(true);
expect(
evaluateCondition(
{ path: "role", op: "not_in", value: ["approver"] },
{ role: "operator" },
),
).toBe(true);
expect(
evaluateCondition({ path: "x", op: "exists" }, { x: 0 }),
).toBe(true);
expect(
evaluateCondition(
{ path: "hash", op: "matches", value: "^0x[0-9a-f]+$" },
{ hash: "0xabc" },
),
).toBe(true);
});
it("length_gte / length_lte work on arrays and strings", () => {
expect(
evaluateCondition({ path: "a", op: "length_gte", value: 2 }, { a: [1, 2] }),
).toBe(true);
expect(
evaluateCondition({ path: "a", op: "length_lte", value: 5 }, { a: "abcd" }),
).toBe(true);
});
it("dotted + indexed path resolution", () => {
expect(
evaluateCondition(
{ path: "plan.steps[1].type", op: "eq", value: "pay" },
{ plan: { steps: [{ type: "issue" }, { type: "pay" }] } },
),
).toBe(true);
});
});
describe("rulesEngine — combinators", () => {
const ctx = { role: "approver", amount: 1000 };
it("all (AND) — every child must pass", () => {
expect(
evaluateCondition(
{
all: [
{ path: "role", op: "eq", value: "approver" },
{ path: "amount", op: "gt", value: 500 },
],
},
ctx,
),
).toBe(true);
expect(
evaluateCondition(
{
all: [
{ path: "role", op: "eq", value: "approver" },
{ path: "amount", op: "gt", value: 5000 },
],
},
ctx,
),
).toBe(false);
});
it("any (OR) — at least one child must pass", () => {
expect(
evaluateCondition(
{
any: [
{ path: "role", op: "eq", value: "releaser" },
{ path: "amount", op: "gt", value: 500 },
],
},
ctx,
),
).toBe(true);
});
it("not — inverts the child", () => {
expect(
evaluateCondition(
{ not: { path: "role", op: "eq", value: "releaser" } },
ctx,
),
).toBe(true);
});
});
describe("rulesEngine — evaluate() and failure reporting", () => {
const ruleSet: RuleSet = {
id: "test.rs",
rules: [
{
id: "amount_positive",
description: "amount must be > 0",
assert: { path: "amount", op: "gt", value: 0 },
},
{
id: "role_listed",
description: "role must be in the allowed list",
assert: {
path: "role",
op: "in",
value: ["approver", "releaser", "operator"],
},
},
{
id: "warning_only",
description: "low amount warning",
severity: "warn",
assert: { path: "amount", op: "gte", value: 10_000 },
},
],
};
it("returns ok=true when all error-severity rules pass", () => {
const res = evaluate(ruleSet, { amount: 1000, role: "approver" });
expect(res.ok).toBe(true);
// warn still reported even though ok=true
expect(res.failures.some((f) => f.ruleId === "warning_only")).toBe(true);
expect(res.failures.every((f) => f.severity === "warn")).toBe(true);
});
it("returns ok=false with error failure when a blocking rule fails", () => {
const res = evaluate(ruleSet, { amount: -1, role: "approver" });
expect(res.ok).toBe(false);
const amountFail = res.failures.find((f) => f.ruleId === "amount_positive");
expect(amountFail?.severity).toBe("error");
});
it("'when' gates a rule — false when-clause skips the assert", () => {
const guarded: RuleSet = {
id: "guarded.rs",
rules: [
{
id: "kyc_if_present",
when: { path: "compliance", op: "exists" },
assert: { path: "compliance.kyc", op: "eq", value: "ok" },
},
],
};
expect(evaluate(guarded, {}).ok).toBe(true);
expect(evaluate(guarded, { compliance: { kyc: "ok" } }).ok).toBe(true);
expect(evaluate(guarded, { compliance: { kyc: "fail" } }).ok).toBe(false);
});
});
describe("rulesEngine — built-in rule sets", () => {
it("preconditions: pay step + non-empty participants passes", () => {
const res = evaluate(BUILTIN_PRECONDITIONS, {
plan: { steps: [{ type: "pay" }] },
participants: [{ id: "p1" }],
});
expect(res.ok).toBe(true);
});
it("preconditions: missing pay step fails", () => {
const res = evaluate(BUILTIN_PRECONDITIONS, {
plan: { steps: [{ type: "issueInstrument" }] },
participants: [{ id: "p1" }],
});
expect(res.ok).toBe(false);
expect(res.failures.some((f) => f.ruleId === "plan.pay_step_present")).toBe(
true,
);
});
it("commit: VALIDATING + matching refs + no exceptions passes", () => {
const res = evaluate(BUILTIN_COMMIT, {
state: "VALIDATING",
dlt: { txHash: `0x${"a".repeat(64)}` },
bank: { isoMessageId: "MSG-1" },
exceptions: { active: [] },
});
expect(res.ok).toBe(true);
});
it("commit: state != VALIDATING blocks", () => {
const res = evaluate(BUILTIN_COMMIT, {
state: "EXECUTING",
dlt: { txHash: `0x${"a".repeat(64)}` },
bank: { isoMessageId: "MSG-1" },
exceptions: { active: [] },
});
expect(res.ok).toBe(false);
expect(res.failures.some((f) => f.ruleId === "state.is_validating")).toBe(
true,
);
});
});
describe("rulesEngine — pluggable loading", () => {
beforeEach(() => {
__resetRulesCacheForTests();
delete process.env.RULES_FILE;
});
it("returns built-ins when RULES_FILE is unset", () => {
expect(getRuleSet(BUILTIN_PRECONDITIONS.id).rules.length).toBeGreaterThan(0);
expect(getRuleSet(BUILTIN_COMMIT.id).rules.length).toBeGreaterThan(0);
});
it("returns an empty rule set for unknown ids (no throw)", () => {
const rs = getRuleSet("nonexistent");
expect(rs.rules).toEqual([]);
});
});

View File

@@ -0,0 +1,169 @@
import { describe, it, expect } from "@jest/globals";
import {
generateMt760,
messageHash,
generatePacs009,
generateMt202,
parseCamt025,
parseCamt054,
parseCamt,
reconcileCamt054,
} from "../../src/services/swift";
import type { InstrumentTerms, Plan } from "../../src/types/plan";
const TERMS: InstrumentTerms = {
applicant: "ACME TRADING FZE",
issuingBankBIC: "EBILAEAD",
beneficiaryBankBIC: "EMBKAEAD",
beneficiaryName: "BLUE OCEAN SHIPPING LLC",
beneficiaryAccount: "AE070260001015104203701",
amount: 1_500_000,
currency: "USD",
tenor: "365D",
expiryDate: "2027-04-18",
placeOfPresentation: "DUBAI",
governingLaw: "URDG 758",
templateRef: "EIB-SBLC-2024-01",
templateHash: "a".repeat(64),
};
const PLAN: Plan = {
plan_id: "11111111-2222-3333-4444-555555555555",
creator: "0xabc",
steps: [{ type: "pay", asset: "USD", amount: 1_500_000 }],
};
describe("SWIFT gateway — MT760", () => {
it("renders all 12 required tags", () => {
const msg = generateMt760(TERMS, { transactionReference: "TXN1", issueDate: "2026-04-18" });
expect(msg.sender).toBe("EBILAEAD");
expect(msg.receiver).toBe("EMBKAEAD");
expect(msg.fields["20"]).toBe("TXN1");
expect(msg.fields["30"]).toBe("260418");
expect(msg.fields["32B"]).toBe("USD1500000,00");
expect(msg.fields["31D"]).toBe("270418DUBAI");
expect(msg.fin).toContain("{1:F01EBILAEADXXXX0000000000}");
expect(msg.fin).toContain("{2:I760EMBKAEADXXXXN}");
expect(msg.fin).toContain(":32B:USD1500000,00");
});
it("rejects malformed expiry date", () => {
expect(() =>
generateMt760({ ...TERMS, expiryDate: "not-a-date" }, { transactionReference: "T", issueDate: "2026-04-18" }),
).toThrow(/YYYY-MM-DD/);
});
it("rejects negative amount", () => {
expect(() =>
generateMt760({ ...TERMS, amount: -1 }, { transactionReference: "T", issueDate: "2026-04-18" }),
).toThrow(/non-negative/);
});
it("messageHash is deterministic", () => {
const a = generateMt760(TERMS, { transactionReference: "T", issueDate: "2026-04-18" });
const b = generateMt760(TERMS, { transactionReference: "T", issueDate: "2026-04-18" });
expect(messageHash(a)).toBe(messageHash(b));
expect(messageHash(a)).toMatch(/^[0-9a-f]{64}$/);
});
});
describe("SWIFT gateway — pacs.009", () => {
const opts = {
messageId: "MSG-1",
creationDateTime: "2026-04-18T10:00:00Z",
instructingAgentBIC: "EBILAEAD",
instructedAgentBIC: "EMBKAEAD",
debtorAgentBIC: "EBILAEAD",
creditorAgentBIC: "EMBKAEAD",
};
it("emits well-formed pacs.009.001.08 XML", () => {
const result = generatePacs009(PLAN, opts);
expect(result.messageId).toBe("MSG-1");
expect(result.xml).toContain("urn:iso:std:iso:20022:tech:xsd:pacs.009.001.08");
expect(result.xml).toContain("<IntrBkSttlmAmt Ccy=\"USD\">1500000.00</IntrBkSttlmAmt>");
expect(result.xml).toContain("<BICFI>EBILAEAD</BICFI>");
expect(result.xml).toContain("<BICFI>EMBKAEAD</BICFI>");
expect(result.endToEndId).toBe(`E2E-${PLAN.plan_id}`);
});
it("rejects invalid BIC", () => {
expect(() => generatePacs009(PLAN, { ...opts, instructingAgentBIC: "BAD" })).toThrow(/BIC/);
});
it("requires a pay step", () => {
expect(() =>
generatePacs009({ ...PLAN, steps: [{ type: "borrow", amount: 1, asset: "USD" }] }, opts),
).toThrow(/pay/);
});
});
describe("SWIFT gateway — MT202", () => {
it("renders the 6 required tags", () => {
const msg = generateMt202(PLAN, {
transactionReference: "TXN-1",
valueDate: "2026-04-18",
sendingInstitution: "EBILAEAD",
receivingInstitution: "EMBKAEAD",
beneficiaryInstitution: "EMBKAEAD",
});
expect(msg.fields["20"]).toBe("TXN-1");
expect(msg.fields["32A"]).toBe("260418USD1500000,00");
expect(msg.fields["58A"]).toBe("EMBKAEAD");
expect(msg.fin).toContain(":20:TXN-1");
});
});
describe("SWIFT gateway — camt parsers", () => {
it("parseCamt025 extracts status + ids", () => {
const xml = `<?xml version="1.0"?><Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.025.001.05"><Rct><MsgId>R1</MsgId><OrgnlMsgId>MSG-1</OrgnlMsgId><Cd>ACSC</Cd><CreDtTm>2026-04-18T10:01:00Z</CreDtTm></Rct></Document>`;
const r = parseCamt025(xml);
expect(r.type).toBe("camt.025");
expect(r.originalMessageId).toBe("MSG-1");
expect(r.status).toBe("ACSC");
});
it("parseCamt054 extracts credit amount + endToEndId", () => {
const xml = `<?xml version="1.0"?><Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.054.001.08"><BkToCstmrDbtCdtNtfctn><MsgId>N1</MsgId><Ntfctn><Ntry><Amt Ccy="USD">1500000.00</Amt><CdtDbtInd>CRDT</CdtDbtInd><BookgDt><Dt>2026-04-18</Dt></BookgDt><ValDt><Dt>2026-04-18</Dt></ValDt><NtryDtls><TxDtls><Refs><EndToEndId>E2E-plan-1</EndToEndId></Refs></TxDtls></NtryDtls></Ntry></Ntfctn></BkToCstmrDbtCdtNtfctn></Document>`;
const r = parseCamt054(xml);
expect(r.type).toBe("camt.054");
expect(r.creditDebitIndicator).toBe("CRDT");
expect(r.amount).toBe(1_500_000);
expect(r.currency).toBe("USD");
expect(r.endToEndId).toBe("E2E-plan-1");
});
it("parseCamt dispatches on xmlns marker", () => {
const xml025 = `<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.025.001.05"><Rct><MsgId>R</MsgId><OrgnlMsgId>O</OrgnlMsgId><Cd>ACSC</Cd></Rct></Document>`;
expect(parseCamt(xml025).type).toBe("camt.025");
});
it("parseCamt rejects unknown xmlns", () => {
expect(() => parseCamt('<Document xmlns="urn:other"/>')).toThrow(/unsupported/);
});
it("reconcileCamt054 returns empty array when everything matches", () => {
const msg = {
type: "camt.054" as const,
messageId: "N1",
creditDebitIndicator: "CRDT" as const,
amount: 1_500_000,
currency: "USD",
endToEndId: "E2E-1",
};
expect(reconcileCamt054(msg, { amount: 1_500_000, currency: "USD", endToEndId: "E2E-1" })).toEqual([]);
});
it("reconcileCamt054 reports amount + currency + direction mismatches", () => {
const msg = {
type: "camt.054" as const,
messageId: "N1",
creditDebitIndicator: "DBIT" as const,
amount: 1_400_000,
currency: "EUR",
endToEndId: "E2E-2",
};
const result = reconcileCamt054(msg, { amount: 1_500_000, currency: "USD", endToEndId: "E2E-1" });
expect(result.map((m) => m.field).sort()).toEqual(["amount", "creditDebitIndicator", "currency", "endToEndId"]);
});
});

View File

@@ -7,6 +7,7 @@ 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';
@@ -131,6 +132,28 @@ 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={

View File

@@ -4,12 +4,13 @@ import { useAuth } from '../../contexts/AuthContext';
import {
LayoutDashboard, Zap, Building2, Landmark, FileText, Shield, CheckSquare,
Settings, LogOut, ChevronLeft, ChevronRight, Bell, User, Copy,
ExternalLink, ChevronDown
ExternalLink, ChevronDown, GitBranch
} 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' },

View File

@@ -0,0 +1,51 @@
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>
);
}

View File

@@ -40,6 +40,13 @@ 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 ?? {};
@@ -66,12 +73,16 @@ 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';
id: 'chain138' | 'explorer' | 'proxmox' | 'dbisCore' | 'orchestrator';
name: string;
status: BackendStatus;
url: string;
@@ -107,4 +118,13 @@ 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.',
},
];

View File

@@ -3851,3 +3851,96 @@ 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; }

View File

@@ -0,0 +1,243 @@
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>
);
}

View File

@@ -0,0 +1,281 @@
/**
* 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,
}));
}