Compare commits
12 Commits
d425f75d02
...
devin/1776
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17745b3aea | ||
| d7d3e80bff | |||
| 2c72a51a06 | |||
| b77ebce497 | |||
| 351bb472b6 | |||
| b66ec0a78f | |||
| 3ef71332dc | |||
| fd575000fe | |||
| cb376eda31 | |||
| b4d28c77d8 | |||
| 84f199fb65 | |||
| c732c1c71a |
42
.github/workflows/ci.yml
vendored
42
.github/workflows/ci.yml
vendored
@@ -108,6 +108,48 @@ jobs:
|
||||
working-directory: orchestrator
|
||||
run: npm run build
|
||||
|
||||
orchestrator-test:
|
||||
name: Orchestrator Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "18"
|
||||
cache: "npm"
|
||||
cache-dependency-path: orchestrator/package-lock.json
|
||||
- name: Install dependencies
|
||||
working-directory: orchestrator
|
||||
run: npm ci
|
||||
- name: Type check
|
||||
working-directory: orchestrator
|
||||
run: npx tsc --noEmit
|
||||
- name: Unit tests
|
||||
working-directory: orchestrator
|
||||
run: npm test
|
||||
|
||||
orchestrator-e2e:
|
||||
name: Orchestrator E2E (Testcontainers)
|
||||
runs-on: ubuntu-latest
|
||||
# Gap-analysis v2 §7.8 / §10.8 — opt-in E2E suite that brings up
|
||||
# a real Postgres container and exercises the lifecycle against it.
|
||||
# Gated on a workflow label so PR runs default to the fast unit
|
||||
# suite; add the `run-e2e` label to a PR to include this job.
|
||||
if: contains(github.event.pull_request.labels.*.name, 'run-e2e') || github.event_name == 'push'
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "18"
|
||||
cache: "npm"
|
||||
cache-dependency-path: orchestrator/package-lock.json
|
||||
- name: Install dependencies
|
||||
working-directory: orchestrator
|
||||
run: npm ci
|
||||
- name: E2E tests (Testcontainers Postgres)
|
||||
working-directory: orchestrator
|
||||
run: npm run test:e2e
|
||||
|
||||
# Smart Contracts CI
|
||||
contracts-compile:
|
||||
name: Contracts Compile
|
||||
|
||||
@@ -4,6 +4,6 @@ module.exports = {
|
||||
testEnvironment: "node",
|
||||
roots: ["<rootDir>/tests"],
|
||||
testMatch: ["**/*.test.ts"],
|
||||
testPathIgnorePatterns: ["/node_modules/", "/integration/", "/chaos/", "/load/"],
|
||||
testPathIgnorePatterns: ["/node_modules/", "/integration/", "/chaos/", "/load/", "/e2e/"],
|
||||
moduleFileExtensions: ["ts", "js", "json"],
|
||||
};
|
||||
|
||||
18
orchestrator/jest.e2e.config.js
Normal file
18
orchestrator/jest.e2e.config.js
Normal file
@@ -0,0 +1,18 @@
|
||||
/** @type {import('jest').Config} */
|
||||
// E2E suite — runs the Testcontainers-backed integration tests
|
||||
// under tests/e2e/. Separate from the default jest.config.js because
|
||||
// it requires Docker and takes significantly longer.
|
||||
//
|
||||
// Usage:
|
||||
// RUN_E2E=1 npx jest --config=jest.e2e.config.js
|
||||
//
|
||||
// CI wires this into a dedicated e2e workflow step so the normal
|
||||
// unit-test suite stays <5s.
|
||||
module.exports = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "node",
|
||||
roots: ["<rootDir>/tests/e2e"],
|
||||
testMatch: ["**/*.e2e.test.ts"],
|
||||
moduleFileExtensions: ["ts", "js", "json"],
|
||||
testTimeout: 120_000,
|
||||
};
|
||||
@@ -8,6 +8,7 @@
|
||||
"dev": "ts-node src/index.ts",
|
||||
"start": "node dist/index.js",
|
||||
"test": "jest",
|
||||
"test:e2e": "RUN_E2E=1 jest --config=jest.e2e.config.js",
|
||||
"migrate": "ts-node src/db/migrations/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -27,6 +28,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "^30.3.0",
|
||||
"@testcontainers/postgresql": "^11.14.0",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^30.0.0",
|
||||
@@ -36,6 +38,7 @@
|
||||
"@types/uuid": "^9.0.6",
|
||||
"jest": "^30.3.0",
|
||||
"supertest": "^7.2.2",
|
||||
"testcontainers": "^11.14.0",
|
||||
"ts-jest": "^29.4.9",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.3.3"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
43
orchestrator/src/db/migrations/003_events.ts
Normal file
43
orchestrator/src/db/migrations/003_events.ts
Normal 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");
|
||||
}
|
||||
44
orchestrator/src/db/migrations/004_idempotency_keys.ts
Normal file
44
orchestrator/src/db/migrations/004_idempotency_keys.ts
Normal 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");
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -109,6 +112,19 @@ app.get("/api/proxmox/cluster/status", proxmoxClusterStatus);
|
||||
|
||||
app.get("/api/plans/:planId/status/stream", streamPlanStatus);
|
||||
|
||||
// FIN-link sandbox transport (gap-analysis v2 §7.1 / §10.6).
|
||||
// Mounted only when FIN_SANDBOX_ENABLED=true so production builds
|
||||
// don't expose the in-memory fake. Intended for dev + E2E only.
|
||||
if (process.env.FIN_SANDBOX_ENABLED === "true") {
|
||||
import("./services/finLink/sandbox").then(({ buildSandboxRouter, startAutoProgress }) => {
|
||||
app.use("/fin-sandbox", buildSandboxRouter());
|
||||
if (process.env.FIN_SANDBOX_AUTO_PROGRESS !== "false") {
|
||||
startAutoProgress(Number(process.env.FIN_SANDBOX_TICK_MS || 2000));
|
||||
}
|
||||
logger.info({ route: "/fin-sandbox" }, "FIN-link sandbox mounted");
|
||||
});
|
||||
}
|
||||
|
||||
// Error handling middleware
|
||||
import { errorHandler } from "./services/errorHandler";
|
||||
import { initRedis } from "./services/redis";
|
||||
|
||||
120
orchestrator/src/middleware/idempotency.ts
Normal file
120
orchestrator/src/middleware/idempotency.ts
Normal 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 };
|
||||
@@ -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";
|
||||
|
||||
|
||||
197
orchestrator/src/services/eventBus.ts
Normal file
197
orchestrator/src/services/eventBus.ts
Normal 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;
|
||||
76
orchestrator/src/services/finLink/client.ts
Normal file
76
orchestrator/src/services/finLink/client.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* FIN-link client (gap-analysis v2 §7.1 / §10.6).
|
||||
*
|
||||
* Thin wrapper around the outbound dispatch API. In dev / E2E it
|
||||
* talks to the sandbox server mounted at FIN_SANDBOX_URL. In
|
||||
* production it should talk to a real FIN / Alliance Access gateway
|
||||
* that exposes the same minimal surface.
|
||||
*
|
||||
* The SWIFT message generators live in `services/swift/`; this
|
||||
* client is the transport hop that PR E was missing.
|
||||
*/
|
||||
|
||||
import type {
|
||||
DispatchRequest,
|
||||
DispatchResponse,
|
||||
FinMessage,
|
||||
} from "./sandbox";
|
||||
|
||||
export interface FinLinkClient {
|
||||
dispatch(req: DispatchRequest): Promise<DispatchResponse>;
|
||||
getMessage(reference: string): Promise<FinMessage | null>;
|
||||
}
|
||||
|
||||
export function createHttpFinLinkClient(baseUrl: string): FinLinkClient {
|
||||
const base = baseUrl.replace(/\/$/, "");
|
||||
return {
|
||||
async dispatch(req) {
|
||||
const resp = await fetch(`${base}/dispatch`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`fin dispatch failed: ${resp.status}`);
|
||||
}
|
||||
return (await resp.json()) as DispatchResponse;
|
||||
},
|
||||
async getMessage(reference) {
|
||||
const resp = await fetch(`${base}/messages/${encodeURIComponent(reference)}`);
|
||||
if (resp.status === 404) return null;
|
||||
if (!resp.ok) throw new Error(`fin getMessage failed: ${resp.status}`);
|
||||
return (await resp.json()) as FinMessage;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* In-process client that talks to the sandbox module directly —
|
||||
* avoids a round-trip through HTTP for unit tests.
|
||||
*/
|
||||
export async function createInProcessFinLinkClient(): Promise<FinLinkClient> {
|
||||
const sandbox = await import("./sandbox");
|
||||
return {
|
||||
async dispatch(req) {
|
||||
const msg = sandbox.recordDispatch(req);
|
||||
return {
|
||||
reference: msg.reference,
|
||||
state: msg.state,
|
||||
ackedAt: msg.updatedAt,
|
||||
};
|
||||
},
|
||||
async getMessage(reference) {
|
||||
return sandbox.getMessage(reference) ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory: returns an HTTP client if FIN_SANDBOX_URL is set, else an
|
||||
* in-process client that short-circuits to the sandbox module.
|
||||
*/
|
||||
export async function getFinLinkClient(): Promise<FinLinkClient> {
|
||||
const url = process.env.FIN_SANDBOX_URL;
|
||||
if (url) return createHttpFinLinkClient(url);
|
||||
return createInProcessFinLinkClient();
|
||||
}
|
||||
28
orchestrator/src/services/finLink/index.ts
Normal file
28
orchestrator/src/services/finLink/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* FIN-link public surface.
|
||||
*/
|
||||
|
||||
export {
|
||||
buildSandboxRouter,
|
||||
recordDispatch,
|
||||
advance,
|
||||
rejectMessage,
|
||||
getMessage,
|
||||
listMessages,
|
||||
resetSandboxForTests,
|
||||
startAutoProgress,
|
||||
stopAutoProgress,
|
||||
finSignature,
|
||||
type FinMessage,
|
||||
type FinMessageState,
|
||||
type FinMessageType,
|
||||
type DispatchRequest,
|
||||
type DispatchResponse,
|
||||
} from "./sandbox";
|
||||
|
||||
export {
|
||||
createHttpFinLinkClient,
|
||||
createInProcessFinLinkClient,
|
||||
getFinLinkClient,
|
||||
type FinLinkClient,
|
||||
} from "./client";
|
||||
274
orchestrator/src/services/finLink/sandbox.ts
Normal file
274
orchestrator/src/services/finLink/sandbox.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* FIN-link sandbox (gap-analysis v2 §7.1 / §10.6).
|
||||
*
|
||||
* The SWIFT generators under `services/swift/` produce strings — but
|
||||
* the architecture note §4.3 requires an actual transport. Until a
|
||||
* production FIN-link / Alliance Access integration ships, this
|
||||
* sandbox service stands in as the outbound transport so the full
|
||||
* lifecycle (dispatch → ack → accept → settle) can be exercised end
|
||||
* to end in dev + E2E.
|
||||
*
|
||||
* The sandbox:
|
||||
*
|
||||
* 1. Accepts an outbound SWIFT/ISO payload via POST /dispatch.
|
||||
* 2. Assigns a FIN reference, stores the message in memory, and
|
||||
* returns a synchronous ack (200).
|
||||
* 3. Advances the message through a deterministic lifecycle:
|
||||
* received -> acknowledged -> accepted -> settled
|
||||
* on each tick of an internal clock (configurable via
|
||||
* setTickIntervalMs for tests).
|
||||
* 4. Exposes GET /messages/:reference + GET /messages for polling.
|
||||
* 5. Optionally POSTs a webhook on each state change when a caller
|
||||
* supplies `webhookUrl` in the dispatch request.
|
||||
*
|
||||
* The sandbox is intentionally process-local. Production transports
|
||||
* should back this interface with a real FIN queue / Alliance Web
|
||||
* Platform gateway.
|
||||
*/
|
||||
|
||||
import { createHmac, randomBytes } from "crypto";
|
||||
import express, { Router, type Request, type Response } from "express";
|
||||
|
||||
export type FinMessageState =
|
||||
| "received"
|
||||
| "acknowledged"
|
||||
| "accepted"
|
||||
| "settled"
|
||||
| "rejected";
|
||||
|
||||
export type FinMessageType =
|
||||
| "MT760"
|
||||
| "MT202"
|
||||
| "pacs.009"
|
||||
| "pacs.008"
|
||||
| "camt.025"
|
||||
| "camt.054"
|
||||
| "unknown";
|
||||
|
||||
export interface FinMessage {
|
||||
reference: string;
|
||||
messageType: FinMessageType;
|
||||
payload: string;
|
||||
state: FinMessageState;
|
||||
receivedAt: string;
|
||||
updatedAt: string;
|
||||
stateHistory: Array<{ state: FinMessageState; at: string }>;
|
||||
webhookUrl?: string;
|
||||
planId?: string;
|
||||
endToEndId?: string;
|
||||
}
|
||||
|
||||
export interface DispatchRequest {
|
||||
messageType: FinMessageType;
|
||||
payload: string;
|
||||
planId?: string;
|
||||
endToEndId?: string;
|
||||
webhookUrl?: string;
|
||||
}
|
||||
|
||||
export interface DispatchResponse {
|
||||
reference: string;
|
||||
state: FinMessageState;
|
||||
ackedAt: string;
|
||||
}
|
||||
|
||||
const store = new Map<string, FinMessage>();
|
||||
|
||||
// Deterministic lifecycle progression.
|
||||
const ORDER: FinMessageState[] = [
|
||||
"received",
|
||||
"acknowledged",
|
||||
"accepted",
|
||||
"settled",
|
||||
];
|
||||
|
||||
function nextState(current: FinMessageState): FinMessageState | null {
|
||||
const idx = ORDER.indexOf(current);
|
||||
if (idx < 0 || idx === ORDER.length - 1) return null;
|
||||
return ORDER[idx + 1];
|
||||
}
|
||||
|
||||
function genReference(): string {
|
||||
return `FIN-${randomBytes(6).toString("hex").toUpperCase()}`;
|
||||
}
|
||||
|
||||
export function finSignature(payload: string): string {
|
||||
const secret = process.env.FIN_SANDBOX_SECRET || "fin-sandbox-dev-secret";
|
||||
return createHmac("sha256", secret).update(payload).digest("hex");
|
||||
}
|
||||
|
||||
export function recordDispatch(req: DispatchRequest): FinMessage {
|
||||
const reference = genReference();
|
||||
const now = new Date().toISOString();
|
||||
const msg: FinMessage = {
|
||||
reference,
|
||||
messageType: req.messageType,
|
||||
payload: req.payload,
|
||||
state: "received",
|
||||
receivedAt: now,
|
||||
updatedAt: now,
|
||||
stateHistory: [{ state: "received", at: now }],
|
||||
webhookUrl: req.webhookUrl,
|
||||
planId: req.planId,
|
||||
endToEndId: req.endToEndId,
|
||||
};
|
||||
store.set(reference, msg);
|
||||
return msg;
|
||||
}
|
||||
|
||||
export async function advance(reference: string): Promise<FinMessage | null> {
|
||||
const msg = store.get(reference);
|
||||
if (!msg) return null;
|
||||
const next = nextState(msg.state);
|
||||
if (!next) return msg;
|
||||
const at = new Date().toISOString();
|
||||
msg.state = next;
|
||||
msg.updatedAt = at;
|
||||
msg.stateHistory.push({ state: next, at });
|
||||
if (msg.webhookUrl) {
|
||||
await emitWebhook(msg).catch(() => undefined);
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
export function rejectMessage(
|
||||
reference: string,
|
||||
reason: string,
|
||||
): FinMessage | null {
|
||||
const msg = store.get(reference);
|
||||
if (!msg) return null;
|
||||
const at = new Date().toISOString();
|
||||
msg.state = "rejected";
|
||||
msg.updatedAt = at;
|
||||
msg.stateHistory.push({ state: "rejected", at });
|
||||
(msg as FinMessage & { rejectionReason?: string }).rejectionReason = reason;
|
||||
return msg;
|
||||
}
|
||||
|
||||
export function getMessage(reference: string): FinMessage | undefined {
|
||||
return store.get(reference);
|
||||
}
|
||||
|
||||
export function listMessages(filter?: { planId?: string }): FinMessage[] {
|
||||
const all = Array.from(store.values());
|
||||
if (!filter?.planId) return all;
|
||||
return all.filter((m) => m.planId === filter.planId);
|
||||
}
|
||||
|
||||
export function resetSandboxForTests(): void {
|
||||
store.clear();
|
||||
}
|
||||
|
||||
async function emitWebhook(msg: FinMessage): Promise<void> {
|
||||
if (!msg.webhookUrl) return;
|
||||
const body = JSON.stringify({
|
||||
reference: msg.reference,
|
||||
messageType: msg.messageType,
|
||||
state: msg.state,
|
||||
updatedAt: msg.updatedAt,
|
||||
planId: msg.planId,
|
||||
endToEndId: msg.endToEndId,
|
||||
});
|
||||
const signature = finSignature(body);
|
||||
try {
|
||||
await fetch(msg.webhookUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-fin-sandbox-signature": signature,
|
||||
},
|
||||
body,
|
||||
});
|
||||
} catch {
|
||||
// swallow — the sandbox is best-effort in dev
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP router
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildSandboxRouter(): Router {
|
||||
const r = Router();
|
||||
r.use(express.json({ limit: "5mb" }));
|
||||
|
||||
r.post("/dispatch", (req: Request, res: Response) => {
|
||||
const body = req.body as Partial<DispatchRequest>;
|
||||
if (
|
||||
!body ||
|
||||
typeof body.payload !== "string" ||
|
||||
typeof body.messageType !== "string"
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: "messageType and payload are required",
|
||||
});
|
||||
}
|
||||
const msg = recordDispatch({
|
||||
messageType: body.messageType as FinMessageType,
|
||||
payload: body.payload,
|
||||
planId: body.planId,
|
||||
endToEndId: body.endToEndId,
|
||||
webhookUrl: body.webhookUrl,
|
||||
});
|
||||
const response: DispatchResponse = {
|
||||
reference: msg.reference,
|
||||
state: msg.state,
|
||||
ackedAt: msg.updatedAt,
|
||||
};
|
||||
return res.status(202).json(response);
|
||||
});
|
||||
|
||||
r.post("/advance/:reference", async (req: Request, res: Response) => {
|
||||
const msg = await advance(req.params.reference);
|
||||
if (!msg) return res.status(404).json({ error: "not found" });
|
||||
return res.json(msg);
|
||||
});
|
||||
|
||||
r.post("/reject/:reference", (req: Request, res: Response) => {
|
||||
const reason =
|
||||
typeof req.body?.reason === "string" ? req.body.reason : "rejected";
|
||||
const msg = rejectMessage(req.params.reference, reason);
|
||||
if (!msg) return res.status(404).json({ error: "not found" });
|
||||
return res.json(msg);
|
||||
});
|
||||
|
||||
r.get("/messages/:reference", (req: Request, res: Response) => {
|
||||
const msg = getMessage(req.params.reference);
|
||||
if (!msg) return res.status(404).json({ error: "not found" });
|
||||
return res.json(msg);
|
||||
});
|
||||
|
||||
r.get("/messages", (req: Request, res: Response) => {
|
||||
const planId =
|
||||
typeof req.query.planId === "string" ? req.query.planId : undefined;
|
||||
return res.json({ messages: listMessages({ planId }) });
|
||||
});
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Timer-driven auto-progress (optional; off by default in tests)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let tickTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
export function startAutoProgress(intervalMs = 2_000): void {
|
||||
stopAutoProgress();
|
||||
tickTimer = setInterval(() => {
|
||||
for (const msg of store.values()) {
|
||||
if (msg.state !== "settled" && msg.state !== "rejected") {
|
||||
void advance(msg.reference);
|
||||
}
|
||||
}
|
||||
}, intervalMs);
|
||||
// Allow the Node process to exit while this timer is pending.
|
||||
if (typeof tickTimer.unref === "function") tickTimer.unref();
|
||||
}
|
||||
|
||||
export function stopAutoProgress(): void {
|
||||
if (tickTimer) {
|
||||
clearInterval(tickTimer);
|
||||
tickTimer = null;
|
||||
}
|
||||
}
|
||||
45
orchestrator/src/services/obligations/evaluator.ts
Normal file
45
orchestrator/src/services/obligations/evaluator.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Obligation-layer condition evaluator.
|
||||
*
|
||||
* Originally shipped as a self-contained subset of the PR P Rules
|
||||
* Engine so the obligation layer could be merged independently. Now
|
||||
* consolidated: this file re-exports the shared types and
|
||||
* `evaluateCondition` from `services/rulesEngine.ts` and provides a
|
||||
* thin compatibility wrapper for `resolvePath(path, context)` which
|
||||
* historically took its arguments in the opposite order.
|
||||
*
|
||||
* Keeping this module as a named surface preserves existing imports
|
||||
* under `services/obligations/evaluator` throughout the codebase and
|
||||
* the test suite.
|
||||
*/
|
||||
|
||||
export type {
|
||||
Operator,
|
||||
LeafCondition,
|
||||
AndCondition,
|
||||
OrCondition,
|
||||
NotCondition,
|
||||
Condition,
|
||||
} from "../rulesEngine";
|
||||
|
||||
import { evaluateCondition as ruleEngineEvaluate, resolvePath as ruleEnginePath } from "../rulesEngine";
|
||||
import type { Condition } from "../rulesEngine";
|
||||
|
||||
export function evaluateCondition(
|
||||
condition: Condition,
|
||||
context: Record<string, unknown>,
|
||||
): boolean {
|
||||
return ruleEngineEvaluate(condition, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Historical (path, context) signature retained for backward
|
||||
* compatibility with call sites written before the evaluator was
|
||||
* consolidated into the Rules Engine.
|
||||
*/
|
||||
export function resolvePath(
|
||||
path: string,
|
||||
context: Record<string, unknown>,
|
||||
): unknown {
|
||||
return ruleEnginePath(context, path);
|
||||
}
|
||||
320
orchestrator/src/services/obligations/index.ts
Normal file
320
orchestrator/src/services/obligations/index.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* Machine-form obligation layer — entry point.
|
||||
*
|
||||
* See ./types.ts for the architectural shape; this module exposes:
|
||||
* - canonicalize / hashObligationTerms (deterministic identity)
|
||||
* - validateObligationTerms (shape check)
|
||||
* - evaluateObligationTerms (run commit/abort/unwind
|
||||
* clauses against a context
|
||||
* via the PR P rules engine)
|
||||
* - buildIssueInstrumentObligation (helper that derives a
|
||||
* sensible default obligation
|
||||
* shape from a plan's
|
||||
* instrument terms)
|
||||
*/
|
||||
|
||||
import { createHash } from "crypto";
|
||||
import { evaluateCondition } from "./evaluator";
|
||||
import type { InstrumentTerms } from "../../types/plan";
|
||||
import type {
|
||||
AuthorizedParticipant,
|
||||
Consideration,
|
||||
EvaluationResult,
|
||||
GoverningDocument,
|
||||
ObligationClause,
|
||||
ObligationEvaluation,
|
||||
ObligationTerms,
|
||||
} from "./types";
|
||||
|
||||
export * from "./types";
|
||||
|
||||
/**
|
||||
* Deterministic canonical JSON encoding: object keys sorted
|
||||
* lexicographically at every depth, arrays preserved, no whitespace.
|
||||
*
|
||||
* This is what `hashObligationTerms()` hashes, so two obligations
|
||||
* with identical semantic content always hash to the same value
|
||||
* regardless of key insertion order.
|
||||
*/
|
||||
export function canonicalize(value: unknown): string {
|
||||
return JSON.stringify(sortValue(value));
|
||||
}
|
||||
|
||||
function sortValue(v: unknown): unknown {
|
||||
if (v === null || typeof v !== "object") return v;
|
||||
if (Array.isArray(v)) return v.map((x) => sortValue(x));
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const k of Object.keys(v as Record<string, unknown>).sort()) {
|
||||
out[k] = sortValue((v as Record<string, unknown>)[k]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* SHA-256 of the canonical obligation terms, hex-encoded without
|
||||
* 0x prefix. Matches the formatting convention used by
|
||||
* `InstrumentTerms.templateHash`.
|
||||
*/
|
||||
export function hashObligationTerms(terms: ObligationTerms): string {
|
||||
return createHash("sha256").update(canonicalize(terms)).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape validation. Returns a list of human-readable problems; empty
|
||||
* list means the object conforms to `ObligationTerms`.
|
||||
*
|
||||
* Intentionally cheap (no JSON-Schema runtime) — the TypeScript type
|
||||
* plus these assertions catch the bulk of real-world mistakes.
|
||||
*/
|
||||
export function validateObligationTerms(
|
||||
input: unknown,
|
||||
): { ok: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
if (!input || typeof input !== "object") {
|
||||
return { ok: false, errors: ["obligation terms must be an object"] };
|
||||
}
|
||||
const t = input as Partial<ObligationTerms>;
|
||||
|
||||
if (t.version !== "1.0") errors.push("version must be \"1.0\"");
|
||||
|
||||
if (!t.consideration || typeof t.consideration !== "object") {
|
||||
errors.push("consideration missing");
|
||||
} else {
|
||||
const c = t.consideration as Partial<Consideration>;
|
||||
if (!c.payor) errors.push("consideration.payor required");
|
||||
if (!c.payee) errors.push("consideration.payee required");
|
||||
if (!c.currency || !/^[A-Z]{3}$/.test(c.currency))
|
||||
errors.push("consideration.currency must be ISO-4217 (3 uppercase letters)");
|
||||
if (typeof c.amount !== "number" || !(c.amount > 0))
|
||||
errors.push("consideration.amount must be a positive number");
|
||||
}
|
||||
|
||||
for (const arrKey of [
|
||||
"validIssuance",
|
||||
"validPayment",
|
||||
"commit",
|
||||
"abort",
|
||||
"unwind",
|
||||
] as const) {
|
||||
const arr = t[arrKey];
|
||||
if (!Array.isArray(arr)) {
|
||||
errors.push(`${arrKey} must be an array`);
|
||||
continue;
|
||||
}
|
||||
arr.forEach((clause, i) => {
|
||||
if (!clause || typeof clause !== "object") {
|
||||
errors.push(`${arrKey}[${i}] must be an object`);
|
||||
return;
|
||||
}
|
||||
const c = clause as Partial<ObligationClause>;
|
||||
if (!c.id) errors.push(`${arrKey}[${i}].id required`);
|
||||
if (!c.description) errors.push(`${arrKey}[${i}].description required`);
|
||||
if (!c.assert) errors.push(`${arrKey}[${i}].assert required`);
|
||||
if (c.binds && !["instrument", "payment", "both"].includes(c.binds))
|
||||
errors.push(`${arrKey}[${i}].binds must be instrument|payment|both`);
|
||||
});
|
||||
}
|
||||
|
||||
if (!Array.isArray(t.authorizedParticipants)) {
|
||||
errors.push("authorizedParticipants must be an array");
|
||||
} else {
|
||||
t.authorizedParticipants.forEach((p, i) => {
|
||||
const pp = p as Partial<AuthorizedParticipant>;
|
||||
if (!pp.role) errors.push(`authorizedParticipants[${i}].role required`);
|
||||
if (!pp.actorId)
|
||||
errors.push(`authorizedParticipants[${i}].actorId required`);
|
||||
});
|
||||
}
|
||||
|
||||
if (!Array.isArray(t.governingDocuments) || t.governingDocuments.length === 0) {
|
||||
errors.push("governingDocuments must be a non-empty array");
|
||||
} else {
|
||||
t.governingDocuments.forEach((d, i) => {
|
||||
const dd = d as Partial<GoverningDocument>;
|
||||
if (!dd.templateRef)
|
||||
errors.push(`governingDocuments[${i}].templateRef required`);
|
||||
if (!dd.templateHash || !/^[0-9a-fA-F]{64}$/.test(dd.templateHash))
|
||||
errors.push(`governingDocuments[${i}].templateHash must be hex SHA-256`);
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a set of obligation clauses against a live context.
|
||||
*
|
||||
* `context` typically contains the plan, execution state, event chain,
|
||||
* and bank/DLT dispatch evidence — whatever the clauses assert against.
|
||||
*
|
||||
* A failure short-circuits nothing; all clauses are evaluated so the
|
||||
* caller can surface the full list of unmet conditions (arch §12.2).
|
||||
*/
|
||||
export function evaluateClauses(
|
||||
clauses: ObligationClause[],
|
||||
context: Record<string, unknown>,
|
||||
): ObligationEvaluation {
|
||||
const results: EvaluationResult[] = clauses.map((clause) => {
|
||||
let ok = false;
|
||||
let failureReason: string | undefined;
|
||||
try {
|
||||
ok = evaluateCondition(clause.assert, context);
|
||||
if (!ok) failureReason = "assert condition returned false";
|
||||
} catch (err) {
|
||||
ok = false;
|
||||
failureReason =
|
||||
err instanceof Error ? err.message : "unknown evaluator error";
|
||||
}
|
||||
return {
|
||||
clauseId: clause.id,
|
||||
description: clause.description,
|
||||
ok,
|
||||
...(failureReason ? { failureReason } : {}),
|
||||
};
|
||||
});
|
||||
return { ok: results.every((r) => r.ok), results };
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate specifically the commit clauses. Convenience for the
|
||||
* transition coordinator (arch §9.2).
|
||||
*/
|
||||
export function evaluateCommit(
|
||||
terms: ObligationTerms,
|
||||
context: Record<string, unknown>,
|
||||
): ObligationEvaluation {
|
||||
return evaluateClauses(terms.commit, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate specifically the abort clauses (arch §9.3). A true result
|
||||
* here means the transaction MUST abort.
|
||||
*/
|
||||
export function evaluateAbort(
|
||||
terms: ObligationTerms,
|
||||
context: Record<string, unknown>,
|
||||
): ObligationEvaluation {
|
||||
const ev = evaluateClauses(terms.abort, context);
|
||||
// Semantically an abort clause that *asserts true* means the abort
|
||||
// condition has been hit, so `ok=true` in the evaluation result ==
|
||||
// "abort required". Callers consume this as a boolean trigger.
|
||||
return ev;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a default obligation-terms object from an issueInstrument
|
||||
* step's instrument terms. Useful for plans that haven't supplied an
|
||||
* explicit obligation block — gives them a reasonable starting point
|
||||
* that matches the template's commit/abort semantics.
|
||||
*/
|
||||
export function buildIssueInstrumentObligation(input: {
|
||||
instrument: InstrumentTerms;
|
||||
payor: string;
|
||||
payee: string;
|
||||
authorizedParticipants: AuthorizedParticipant[];
|
||||
governingDocumentTitle?: string;
|
||||
}): ObligationTerms {
|
||||
const { instrument, payor, payee, authorizedParticipants } = input;
|
||||
|
||||
const commit: ObligationClause[] = [
|
||||
{
|
||||
id: "commit.dlt_tx_hash",
|
||||
description: "DLT anchor transaction hash is present and valid",
|
||||
binds: "both",
|
||||
assert: {
|
||||
path: "dlt.tx_hash",
|
||||
op: "matches",
|
||||
value: "^0x[0-9a-fA-F]{64}$",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "commit.bank_iso_message_id",
|
||||
description: "Bank leg has produced an ISO-20022 message id",
|
||||
binds: "instrument",
|
||||
assert: { path: "bank.iso_message_id", op: "exists" },
|
||||
},
|
||||
{
|
||||
id: "commit.state_is_validating",
|
||||
description: "Transaction must be in VALIDATING when commit fires",
|
||||
binds: "both",
|
||||
assert: { path: "state", op: "eq", value: "VALIDATING" },
|
||||
},
|
||||
];
|
||||
|
||||
const abort: ObligationClause[] = [
|
||||
{
|
||||
id: "abort.exception_raised",
|
||||
description: "At least one active exception blocks commit",
|
||||
binds: "both",
|
||||
assert: { path: "exceptions.active", op: "length_gte", value: 1 },
|
||||
},
|
||||
];
|
||||
|
||||
const unwind: ObligationClause[] = [
|
||||
{
|
||||
id: "unwind.payment_failed_only",
|
||||
description:
|
||||
"Unwind applies only when the payment leg failed AFTER the "
|
||||
+ "instrument was dispatched (MT760 is irrevocable under UCP 600).",
|
||||
binds: "payment",
|
||||
assert: {
|
||||
all: [
|
||||
{ path: "instrument.dispatched", op: "eq", value: true },
|
||||
{ path: "payment.failed", op: "eq", value: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const validIssuance: ObligationClause[] = [
|
||||
{
|
||||
id: "issuance.template_hash_matches",
|
||||
description: "Dispatched instrument text hashes to the agreed template",
|
||||
binds: "instrument",
|
||||
assert: {
|
||||
path: "instrument.template_hash",
|
||||
op: "eq",
|
||||
value: instrument.templateHash,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const validPayment: ObligationClause[] = [
|
||||
{
|
||||
id: "payment.amount_matches",
|
||||
description: "Payment amount equals the instrument face value",
|
||||
binds: "payment",
|
||||
assert: { path: "payment.amount", op: "eq", value: instrument.amount },
|
||||
},
|
||||
{
|
||||
id: "payment.currency_matches",
|
||||
description: "Payment currency equals the instrument currency",
|
||||
binds: "payment",
|
||||
assert: { path: "payment.currency", op: "eq", value: instrument.currency },
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
version: "1.0",
|
||||
consideration: {
|
||||
payor,
|
||||
payee,
|
||||
currency: instrument.currency,
|
||||
amount: instrument.amount,
|
||||
},
|
||||
validIssuance,
|
||||
validPayment,
|
||||
commit,
|
||||
abort,
|
||||
unwind,
|
||||
authorizedParticipants,
|
||||
governingDocuments: [
|
||||
{
|
||||
templateRef: instrument.templateRef,
|
||||
templateHash: instrument.templateHash,
|
||||
title: input.governingDocumentTitle,
|
||||
governingLaw: instrument.governingLaw,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
135
orchestrator/src/services/obligations/types.ts
Normal file
135
orchestrator/src/services/obligations/types.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Machine-form obligation layer (gap-analysis v2 §4.1 partial).
|
||||
*
|
||||
* Architecture §4.1 "Legal / Obligation Layer" describes what the
|
||||
* transaction's terms must express: consideration, commit conditions,
|
||||
* abort conditions, unwind conditions, authorized-participant matrix,
|
||||
* and a reference to governing documents.
|
||||
*
|
||||
* Until now a Plan only stored a `templateHash` — a hash reference
|
||||
* to an off-chain text. That satisfies tamper-evidence but is not
|
||||
* machine-enforceable: the orchestrator can't tell whether a given
|
||||
* execution context *satisfies* the terms without a human reading
|
||||
* the underlying PDF.
|
||||
*
|
||||
* This module makes the obligation layer first-class data:
|
||||
*
|
||||
* - Strongly typed shape for the six architectural sub-objects
|
||||
* (consideration, validIssuance, validPayment, commit, abort,
|
||||
* unwind, authorizedParticipants, governingDocuments).
|
||||
* - Canonicalisation + SHA-256 hash (deterministic, replayable).
|
||||
* - Executable assertions built on the PR P Rules Engine DSL so
|
||||
* commit/abort/unwind conditions can be checked automatically
|
||||
* against a live context.
|
||||
*
|
||||
* Binds to the existing `InstrumentTerms.templateHash` field: an
|
||||
* ObligationTerms instance records the governing-document hash as
|
||||
* one of its `governingDocuments[]` entries, closing the loop from
|
||||
* "which document governs this plan" to "what does that document
|
||||
* require, expressed as machine-checkable predicates".
|
||||
*/
|
||||
|
||||
import type { Condition } from "./evaluator";
|
||||
|
||||
/**
|
||||
* Commercial and legal meaning of the transaction (arch §4.1).
|
||||
*/
|
||||
export interface Consideration {
|
||||
/** Who pays and what. */
|
||||
payor: string;
|
||||
payee: string;
|
||||
/** ISO-4217 currency code. */
|
||||
currency: string;
|
||||
/** Positive amount in major units (e.g. 100.00 USD = 100). */
|
||||
amount: number;
|
||||
/** Optional free-form description of the consideration. */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Role entry on the authorized-participant matrix. Roles match the
|
||||
* SoD set used by middleware/apiKeyAuth (PR M): coordinator, approver,
|
||||
* releaser, validator, exception_manager, operator.
|
||||
*/
|
||||
export interface AuthorizedParticipant {
|
||||
role:
|
||||
| "coordinator"
|
||||
| "approver"
|
||||
| "releaser"
|
||||
| "validator"
|
||||
| "exception_manager"
|
||||
| "operator";
|
||||
/** Free-form identifier — an actor id, API-key id, or wallet address. */
|
||||
actorId: string;
|
||||
/** Optional display name. */
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Governing-document reference: template id + integrity hash of the
|
||||
* agreed text (see InstrumentTerms.templateHash).
|
||||
*/
|
||||
export interface GoverningDocument {
|
||||
/** Stable template identifier (e.g. "emirates-islamic-sblc-v3"). */
|
||||
templateRef: string;
|
||||
/** Hex SHA-256 of the canonical agreed text, without 0x prefix. */
|
||||
templateHash: string;
|
||||
/** Optional human-readable title. */
|
||||
title?: string;
|
||||
/** Optional ruleset the template is governed under. */
|
||||
governingLaw?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single machine-enforceable clause. The `assert` field is a
|
||||
* rulesEngine Condition so the obligation layer can reuse the
|
||||
* evaluator from PR P.
|
||||
*/
|
||||
export interface ObligationClause {
|
||||
id: string;
|
||||
description: string;
|
||||
/** Rules-engine condition that must hold for the clause to be satisfied. */
|
||||
assert: Condition;
|
||||
/** Explicitly surface which side of the transaction the clause binds. */
|
||||
binds: "instrument" | "payment" | "both";
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level obligation-terms object.
|
||||
*
|
||||
* Canonicalisation:
|
||||
* - Keys are sorted lexicographically via `canonicalize()`.
|
||||
* - `terms_hash` = SHA-256 of the canonical JSON string.
|
||||
*
|
||||
* The hash is the identity of the obligation: two plans with the
|
||||
* same hash have identical machine-enforceable terms.
|
||||
*/
|
||||
export interface ObligationTerms {
|
||||
/** Schema version — bump on any breaking shape change. */
|
||||
version: "1.0";
|
||||
consideration: Consideration;
|
||||
/** Clauses that define what "valid issuance" means (arch §4.1). */
|
||||
validIssuance: ObligationClause[];
|
||||
/** Clauses that define what "valid payment" means (arch §4.1). */
|
||||
validPayment: ObligationClause[];
|
||||
/** Commit criteria (arch §9.2). */
|
||||
commit: ObligationClause[];
|
||||
/** Abort criteria (arch §9.3). */
|
||||
abort: ObligationClause[];
|
||||
/** Unwind procedures (arch §8 UNWIND_PENDING). */
|
||||
unwind: ObligationClause[];
|
||||
authorizedParticipants: AuthorizedParticipant[];
|
||||
governingDocuments: GoverningDocument[];
|
||||
}
|
||||
|
||||
export interface EvaluationResult {
|
||||
clauseId: string;
|
||||
description: string;
|
||||
ok: boolean;
|
||||
failureReason?: string;
|
||||
}
|
||||
|
||||
export interface ObligationEvaluation {
|
||||
ok: boolean;
|
||||
results: EvaluationResult[];
|
||||
}
|
||||
308
orchestrator/src/services/rulesEngine.ts
Normal file
308
orchestrator/src/services/rulesEngine.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* 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.
|
||||
* --------------------------------------------------------------- */
|
||||
export function resolvePath(ctx: unknown, path: string): unknown {
|
||||
return getPath(ctx, path);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
129
orchestrator/src/services/swift/camt.ts
Normal file
129
orchestrator/src/services/swift/camt.ts
Normal 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;
|
||||
}
|
||||
36
orchestrator/src/services/swift/index.ts
Normal file
36
orchestrator/src/services/swift/index.ts
Normal 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";
|
||||
78
orchestrator/src/services/swift/mt202.ts
Normal file
78
orchestrator/src/services/swift/mt202.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
112
orchestrator/src/services/swift/mt760.ts
Normal file
112
orchestrator/src/services/swift/mt760.ts
Normal 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();
|
||||
}
|
||||
94
orchestrator/src/services/swift/pacs009.ts
Normal file
94
orchestrator/src/services/swift/pacs009.ts
Normal 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) => ({ "<": "<", ">": ">", "&": "&", '"': """, "'": "'" }[c]!));
|
||||
}
|
||||
178
orchestrator/tests/e2e/transactionLifecycle.e2e.test.ts
Normal file
178
orchestrator/tests/e2e/transactionLifecycle.e2e.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* E2E transaction lifecycle (gap-analysis v2 §7.8 / §10.8).
|
||||
*
|
||||
* Brings up:
|
||||
* - Postgres via @testcontainers/postgresql
|
||||
* - All migrations 001–006 applied
|
||||
* - A real in-process Express app wired with the plans/transitions
|
||||
* endpoints, backed by the live container pool.
|
||||
*
|
||||
* Skipped unless RUN_E2E=1 and Docker is reachable. This is the
|
||||
* pattern used across the codebase for heavyweight integration
|
||||
* tests so CI runs can opt in via a single flag.
|
||||
*
|
||||
* NB: Chain-138 RPC, SWIFT gateway, and Redis are all mocked-local
|
||||
* by default. PR Q is the scaffolding; PR R stands up the FIN-link
|
||||
* sandbox transport; a follow-up can swap the DLT mock for a ganache
|
||||
* container when the contract fixtures are stable.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from "@jest/globals";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
|
||||
const shouldRun = process.env.RUN_E2E === "1";
|
||||
|
||||
// Use describe.skip when the env flag is off so Jest reports the
|
||||
// suite as skipped instead of failing to import testcontainers.
|
||||
const d = shouldRun ? describe : describe.skip;
|
||||
|
||||
d("E2E transaction lifecycle (Postgres testcontainer)", () => {
|
||||
let pgContainer: unknown;
|
||||
let connectionString = "";
|
||||
let app: express.Express;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { PostgreSqlContainer } = await import("@testcontainers/postgresql");
|
||||
const container = await new PostgreSqlContainer("postgres:15-alpine")
|
||||
.withDatabase("ccflow_e2e")
|
||||
.withUsername("ccflow")
|
||||
.withPassword("ccflow")
|
||||
.start();
|
||||
pgContainer = container;
|
||||
connectionString = container.getConnectionUri();
|
||||
|
||||
process.env.DATABASE_URL = connectionString;
|
||||
process.env.SESSION_SECRET =
|
||||
"e2e-session-secret-must-be-at-least-32-chars-long!";
|
||||
process.env.NODE_ENV = "test";
|
||||
|
||||
// Import after env set so migrations/pool read the container URL.
|
||||
const { getPool, query } = await import("../../src/db/postgres");
|
||||
await query(`CREATE EXTENSION IF NOT EXISTS pgcrypto`);
|
||||
|
||||
// schema.sql contains $$...$$ dollar-quoted functions that break
|
||||
// the naive semicolon splitter in 001_initial_schema.ts. Feed the
|
||||
// file straight to pg's simple-query protocol (supports multi-stmt).
|
||||
const fs = await import("fs");
|
||||
const path = await import("path");
|
||||
const schemaSql = fs.readFileSync(
|
||||
path.join(__dirname, "../../src/db/schema.sql"),
|
||||
"utf-8",
|
||||
);
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query(schemaSql);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
// Run the numbered migrations after schema.sql.
|
||||
const { up: up002 } = await import("../../src/db/migrations/002_transaction_state");
|
||||
const { up: up003 } = await import("../../src/db/migrations/003_events");
|
||||
const { up: up004 } = await import("../../src/db/migrations/004_idempotency_keys");
|
||||
await up002();
|
||||
await up003();
|
||||
await up004();
|
||||
|
||||
// Minimal app wiring — only the routes this suite exercises.
|
||||
const { createPlan, getPlan } = await import("../../src/api/plans");
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.post("/api/plans", createPlan);
|
||||
app.get("/api/plans/:planId", getPlan);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
const { closePool } = await import("../../src/db/postgres");
|
||||
await closePool();
|
||||
if (pgContainer && typeof (pgContainer as { stop?: () => Promise<void> }).stop === "function") {
|
||||
await (pgContainer as { stop: () => Promise<void> }).stop();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
const validPayStep = {
|
||||
type: "pay",
|
||||
asset: "USD",
|
||||
amount: 100,
|
||||
beneficiary: { IBAN: "AE070331234567890123456", BIC: "EBILAEAD", name: "Beneficiary Co" },
|
||||
};
|
||||
|
||||
it("persists a created plan and reads it back", async () => {
|
||||
const create = await request(app)
|
||||
.post("/api/plans")
|
||||
.send({
|
||||
creator: "0xtest-creator",
|
||||
steps: [validPayStep],
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
expect(create.body.plan_id).toBeDefined();
|
||||
expect(create.body.plan_hash).toMatch(/^[0-9a-fA-F]{64}$/);
|
||||
|
||||
const read = await request(app)
|
||||
.get(`/api/plans/${create.body.plan_id}`)
|
||||
.expect(200);
|
||||
expect(read.body.plan_id).toBe(create.body.plan_id);
|
||||
}, 30_000);
|
||||
|
||||
it("publishes a signed event row via the live event bus", async () => {
|
||||
const create = await request(app)
|
||||
.post("/api/plans")
|
||||
.send({
|
||||
creator: "0xtest-creator-2",
|
||||
steps: [validPayStep],
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
const { publish, getEventsForPlan, verifyChain } = await import(
|
||||
"../../src/services/eventBus"
|
||||
);
|
||||
await publish({
|
||||
planId: create.body.plan_id,
|
||||
type: "transaction.created",
|
||||
actor: "e2e",
|
||||
payload: { plan_hash: create.body.plan_hash },
|
||||
});
|
||||
await publish({
|
||||
planId: create.body.plan_id,
|
||||
type: "transaction.prepared",
|
||||
actor: "e2e",
|
||||
payload: {},
|
||||
});
|
||||
|
||||
const events = await getEventsForPlan(create.body.plan_id);
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0].prev_hash).toBeNull();
|
||||
expect(events[1].prev_hash).toBe(events[0].signature);
|
||||
|
||||
const chain = await verifyChain(create.body.plan_id);
|
||||
expect(chain.ok).toBe(true);
|
||||
}, 30_000);
|
||||
|
||||
it("idempotency_keys table persists a request-id fingerprint", async () => {
|
||||
const { query } = await import("../../src/db/postgres");
|
||||
await query(
|
||||
`INSERT INTO idempotency_keys (key, method, path, request_hash, response_body, status_code)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb, $6)`,
|
||||
["e2e-key-1", "POST", "/api/plans", "h".repeat(64), JSON.stringify({ ok: true }), 201],
|
||||
);
|
||||
const rows = await query<{ key: string }>(
|
||||
`SELECT key FROM idempotency_keys WHERE key = $1`,
|
||||
["e2e-key-1"],
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe("E2E suite guard", () => {
|
||||
it("skipped when RUN_E2E is not set", () => {
|
||||
if (!shouldRun) {
|
||||
expect(shouldRun).toBe(false);
|
||||
return;
|
||||
}
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
149
orchestrator/tests/unit/eventBus.test.ts
Normal file
149
orchestrator/tests/unit/eventBus.test.ts
Normal 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
170
orchestrator/tests/unit/finLinkSandbox.test.ts
Normal file
170
orchestrator/tests/unit/finLinkSandbox.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { describe, it, expect, beforeEach } from "@jest/globals";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
|
||||
import {
|
||||
buildSandboxRouter,
|
||||
recordDispatch,
|
||||
advance,
|
||||
rejectMessage,
|
||||
getMessage,
|
||||
listMessages,
|
||||
resetSandboxForTests,
|
||||
finSignature,
|
||||
} from "../../src/services/finLink/sandbox";
|
||||
import {
|
||||
createInProcessFinLinkClient,
|
||||
createHttpFinLinkClient,
|
||||
} from "../../src/services/finLink/client";
|
||||
|
||||
describe("FIN-link sandbox (gap-analysis v2 §7.1 / §10.6)", () => {
|
||||
beforeEach(() => {
|
||||
resetSandboxForTests();
|
||||
});
|
||||
|
||||
describe("lifecycle (in-memory)", () => {
|
||||
it("assigns a FIN reference and records state received", () => {
|
||||
const msg = recordDispatch({
|
||||
messageType: "MT760",
|
||||
payload: "MT760 payload",
|
||||
planId: "plan-1",
|
||||
});
|
||||
expect(msg.reference).toMatch(/^FIN-[0-9A-F]{12}$/);
|
||||
expect(msg.state).toBe("received");
|
||||
expect(msg.stateHistory).toHaveLength(1);
|
||||
expect(msg.planId).toBe("plan-1");
|
||||
});
|
||||
|
||||
it("advances deterministically: received -> acknowledged -> accepted -> settled", async () => {
|
||||
const msg = recordDispatch({ messageType: "pacs.009", payload: "<pacs.009/>" });
|
||||
expect((await advance(msg.reference))!.state).toBe("acknowledged");
|
||||
expect((await advance(msg.reference))!.state).toBe("accepted");
|
||||
expect((await advance(msg.reference))!.state).toBe("settled");
|
||||
expect((await advance(msg.reference))!.state).toBe("settled"); // terminal
|
||||
const final = getMessage(msg.reference)!;
|
||||
expect(final.stateHistory.map((h) => h.state)).toEqual([
|
||||
"received",
|
||||
"acknowledged",
|
||||
"accepted",
|
||||
"settled",
|
||||
]);
|
||||
});
|
||||
|
||||
it("supports rejection and stops lifecycle progression", async () => {
|
||||
const msg = recordDispatch({ messageType: "MT202", payload: "MT202 payload" });
|
||||
const rejected = rejectMessage(msg.reference, "bad coordinates")!;
|
||||
expect(rejected.state).toBe("rejected");
|
||||
const afterAdvance = await advance(msg.reference);
|
||||
expect(afterAdvance!.state).toBe("rejected");
|
||||
});
|
||||
|
||||
it("listMessages filters by planId", () => {
|
||||
recordDispatch({ messageType: "MT760", payload: "a", planId: "plan-a" });
|
||||
recordDispatch({ messageType: "MT760", payload: "b", planId: "plan-b" });
|
||||
recordDispatch({ messageType: "MT760", payload: "c", planId: "plan-a" });
|
||||
expect(listMessages().length).toBe(3);
|
||||
expect(listMessages({ planId: "plan-a" }).length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("signature", () => {
|
||||
it("produces a stable 64-char hex HMAC", () => {
|
||||
const sig = finSignature("hello");
|
||||
expect(sig).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(finSignature("hello")).toBe(sig);
|
||||
expect(finSignature("world")).not.toBe(sig);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HTTP router", () => {
|
||||
const app = express();
|
||||
app.use("/fin", buildSandboxRouter());
|
||||
|
||||
beforeEach(() => resetSandboxForTests());
|
||||
|
||||
it("POST /fin/dispatch returns 202 + reference", async () => {
|
||||
const resp = await request(app)
|
||||
.post("/fin/dispatch")
|
||||
.send({ messageType: "MT760", payload: "mt760", planId: "plan-x" })
|
||||
.expect(202);
|
||||
expect(resp.body.reference).toMatch(/^FIN-/);
|
||||
expect(resp.body.state).toBe("received");
|
||||
});
|
||||
|
||||
it("POST /fin/dispatch 400s on missing payload", async () => {
|
||||
await request(app)
|
||||
.post("/fin/dispatch")
|
||||
.send({ messageType: "MT760" })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("POST /fin/advance/:ref walks through lifecycle", async () => {
|
||||
const d = await request(app)
|
||||
.post("/fin/dispatch")
|
||||
.send({ messageType: "pacs.009", payload: "<pacs.009/>" })
|
||||
.expect(202);
|
||||
const ref = d.body.reference;
|
||||
const a1 = await request(app).post(`/fin/advance/${ref}`).expect(200);
|
||||
expect(a1.body.state).toBe("acknowledged");
|
||||
const a2 = await request(app).post(`/fin/advance/${ref}`).expect(200);
|
||||
expect(a2.body.state).toBe("accepted");
|
||||
const a3 = await request(app).post(`/fin/advance/${ref}`).expect(200);
|
||||
expect(a3.body.state).toBe("settled");
|
||||
});
|
||||
|
||||
it("GET /fin/messages?planId=... filters", async () => {
|
||||
await request(app)
|
||||
.post("/fin/dispatch")
|
||||
.send({ messageType: "MT760", payload: "a", planId: "p1" });
|
||||
await request(app)
|
||||
.post("/fin/dispatch")
|
||||
.send({ messageType: "MT760", payload: "b", planId: "p2" });
|
||||
const r = await request(app).get("/fin/messages?planId=p1").expect(200);
|
||||
expect(r.body.messages).toHaveLength(1);
|
||||
expect(r.body.messages[0].planId).toBe("p1");
|
||||
});
|
||||
|
||||
it("GET /fin/messages/:ref returns 404 for unknown", async () => {
|
||||
await request(app).get("/fin/messages/FIN-UNKNOWN").expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("client", () => {
|
||||
beforeEach(() => resetSandboxForTests());
|
||||
|
||||
it("createInProcessFinLinkClient dispatches and reads back", async () => {
|
||||
const client = await createInProcessFinLinkClient();
|
||||
const ack = await client.dispatch({
|
||||
messageType: "MT760",
|
||||
payload: "mt760",
|
||||
planId: "plan-ip",
|
||||
});
|
||||
expect(ack.reference).toMatch(/^FIN-/);
|
||||
const msg = await client.getMessage(ack.reference);
|
||||
expect(msg?.planId).toBe("plan-ip");
|
||||
});
|
||||
|
||||
it("createHttpFinLinkClient hits the live router", async () => {
|
||||
const app = express();
|
||||
app.use("/fin", buildSandboxRouter());
|
||||
const server = app.listen(0);
|
||||
try {
|
||||
const addr = server.address();
|
||||
const port = typeof addr === "object" && addr ? addr.port : 0;
|
||||
const client = createHttpFinLinkClient(`http://127.0.0.1:${port}/fin`);
|
||||
const ack = await client.dispatch({
|
||||
messageType: "pacs.009",
|
||||
payload: "<pacs.009/>",
|
||||
planId: "plan-http",
|
||||
});
|
||||
expect(ack.reference).toMatch(/^FIN-/);
|
||||
const msg = await client.getMessage(ack.reference);
|
||||
expect(msg?.messageType).toBe("pacs.009");
|
||||
const missing = await client.getMessage("FIN-DOES-NOT-EXIST");
|
||||
expect(missing).toBeNull();
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
177
orchestrator/tests/unit/idempotency.test.ts
Normal file
177
orchestrator/tests/unit/idempotency.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
284
orchestrator/tests/unit/obligations.test.ts
Normal file
284
orchestrator/tests/unit/obligations.test.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import { describe, it, expect } from "@jest/globals";
|
||||
import {
|
||||
canonicalize,
|
||||
hashObligationTerms,
|
||||
validateObligationTerms,
|
||||
evaluateClauses,
|
||||
evaluateCommit,
|
||||
evaluateAbort,
|
||||
buildIssueInstrumentObligation,
|
||||
type ObligationTerms,
|
||||
} from "../../src/services/obligations";
|
||||
import { evaluateCondition, resolvePath } from "../../src/services/obligations/evaluator";
|
||||
|
||||
describe("Obligation layer (gap-analysis v2 §4.1)", () => {
|
||||
const instrument = {
|
||||
applicant: "ACME Corp",
|
||||
issuingBankBIC: "CHASUS33",
|
||||
beneficiaryBankBIC: "EBILAEAD",
|
||||
beneficiaryName: "Acme Beneficiary Ltd",
|
||||
beneficiaryAccount: "AE070331234567890123456",
|
||||
amount: 1_000_000,
|
||||
currency: "USD",
|
||||
tenor: "1Y",
|
||||
expiryDate: "2026-12-31",
|
||||
placeOfPresentation: "Dubai",
|
||||
governingLaw: "URDG 758",
|
||||
templateRef: "emirates-islamic-sblc-v3",
|
||||
templateHash:
|
||||
"a".repeat(64),
|
||||
};
|
||||
|
||||
const authorizedParticipants = [
|
||||
{ role: "coordinator" as const, actorId: "actor-1" },
|
||||
{ role: "approver" as const, actorId: "actor-2" },
|
||||
{ role: "releaser" as const, actorId: "actor-3" },
|
||||
{ role: "validator" as const, actorId: "actor-4" },
|
||||
{ role: "exception_manager" as const, actorId: "actor-5" },
|
||||
];
|
||||
|
||||
describe("canonicalize()", () => {
|
||||
it("sorts object keys at every depth", () => {
|
||||
const a = canonicalize({ b: 1, a: { d: 2, c: 3 } });
|
||||
const b = canonicalize({ a: { c: 3, d: 2 }, b: 1 });
|
||||
expect(a).toBe(b);
|
||||
expect(a).toBe('{"a":{"c":3,"d":2},"b":1}');
|
||||
});
|
||||
|
||||
it("preserves array order", () => {
|
||||
expect(canonicalize({ x: [3, 1, 2] })).toBe('{"x":[3,1,2]}');
|
||||
});
|
||||
|
||||
it("handles null and nested arrays of objects", () => {
|
||||
expect(
|
||||
canonicalize({ a: null, b: [{ y: 2, x: 1 }, { z: 3 }] }),
|
||||
).toBe('{"a":null,"b":[{"x":1,"y":2},{"z":3}]}');
|
||||
});
|
||||
});
|
||||
|
||||
describe("hashObligationTerms()", () => {
|
||||
const terms = buildIssueInstrumentObligation({
|
||||
instrument,
|
||||
payor: "ACME Corp",
|
||||
payee: "Acme Beneficiary Ltd",
|
||||
authorizedParticipants,
|
||||
});
|
||||
|
||||
it("produces a 64-char hex hash", () => {
|
||||
expect(hashObligationTerms(terms)).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("is insensitive to key ordering", () => {
|
||||
const shuffled: ObligationTerms = {
|
||||
...terms,
|
||||
consideration: {
|
||||
payee: terms.consideration.payee,
|
||||
currency: terms.consideration.currency,
|
||||
amount: terms.consideration.amount,
|
||||
payor: terms.consideration.payor,
|
||||
},
|
||||
};
|
||||
expect(hashObligationTerms(shuffled)).toBe(hashObligationTerms(terms));
|
||||
});
|
||||
|
||||
it("changes when any field mutates", () => {
|
||||
const mutated: ObligationTerms = {
|
||||
...terms,
|
||||
consideration: { ...terms.consideration, amount: 999 },
|
||||
};
|
||||
expect(hashObligationTerms(mutated)).not.toBe(hashObligationTerms(terms));
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateObligationTerms()", () => {
|
||||
const valid = buildIssueInstrumentObligation({
|
||||
instrument,
|
||||
payor: "A",
|
||||
payee: "B",
|
||||
authorizedParticipants,
|
||||
});
|
||||
|
||||
it("accepts a well-formed obligation", () => {
|
||||
expect(validateObligationTerms(valid).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-object input", () => {
|
||||
expect(validateObligationTerms(null).ok).toBe(false);
|
||||
expect(validateObligationTerms("nope").ok).toBe(false);
|
||||
});
|
||||
|
||||
it("flags missing consideration fields", () => {
|
||||
const bad = {
|
||||
...valid,
|
||||
consideration: { payor: "A", payee: "B", currency: "usd", amount: -5 },
|
||||
};
|
||||
const r = validateObligationTerms(bad);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.errors).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("ISO-4217"),
|
||||
expect.stringContaining("amount"),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("flags bad template hash", () => {
|
||||
const bad = {
|
||||
...valid,
|
||||
governingDocuments: [
|
||||
{ templateRef: "t", templateHash: "not-a-hash" },
|
||||
],
|
||||
};
|
||||
const r = validateObligationTerms(bad);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.errors.some((e) => e.includes("hex SHA-256"))).toBe(true);
|
||||
});
|
||||
|
||||
it("flags empty authorizedParticipants[].role", () => {
|
||||
const bad = {
|
||||
...valid,
|
||||
authorizedParticipants: [{ actorId: "x" }],
|
||||
};
|
||||
const r = validateObligationTerms(bad);
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluator", () => {
|
||||
it("resolvePath handles dotted + indexed paths", () => {
|
||||
const ctx = { plan: { steps: [{ type: "pay" }, { type: "issueInstrument" }] } };
|
||||
expect(resolvePath("plan.steps[1].type", ctx)).toBe("issueInstrument");
|
||||
expect(resolvePath("plan.missing.x", ctx)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("evaluates all/any/not combinators", () => {
|
||||
const ctx = { a: 1, b: 2 };
|
||||
expect(
|
||||
evaluateCondition(
|
||||
{
|
||||
all: [
|
||||
{ path: "a", op: "eq", value: 1 },
|
||||
{ path: "b", op: "gt", value: 1 },
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
evaluateCondition(
|
||||
{
|
||||
any: [
|
||||
{ path: "a", op: "eq", value: 99 },
|
||||
{ path: "b", op: "gt", value: 1 },
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
evaluateCondition({ not: { path: "a", op: "eq", value: 2 } }, ctx),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches regex operator safely (no eval)", () => {
|
||||
expect(
|
||||
evaluateCondition(
|
||||
{ path: "h", op: "matches", value: "^0x[0-9a-f]{4}$" },
|
||||
{ h: "0xbeef" },
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
evaluateCondition(
|
||||
{ path: "h", op: "matches", value: "^0x[0-9a-f]{4}$" },
|
||||
{ h: "0xBEEFG" },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateClauses / evaluateCommit / evaluateAbort", () => {
|
||||
const terms = buildIssueInstrumentObligation({
|
||||
instrument,
|
||||
payor: "ACME Corp",
|
||||
payee: "Acme Beneficiary Ltd",
|
||||
authorizedParticipants,
|
||||
});
|
||||
|
||||
const passingCtx = {
|
||||
state: "VALIDATING",
|
||||
dlt: { tx_hash: "0x" + "b".repeat(64) },
|
||||
bank: { iso_message_id: "MSG-1" },
|
||||
exceptions: { active: [] },
|
||||
instrument: { template_hash: instrument.templateHash, dispatched: true },
|
||||
payment: {
|
||||
amount: instrument.amount,
|
||||
currency: instrument.currency,
|
||||
failed: false,
|
||||
},
|
||||
};
|
||||
|
||||
it("evaluateCommit returns ok=true when all commit clauses pass", () => {
|
||||
const r = evaluateCommit(terms, passingCtx);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.results.every((x) => x.ok)).toBe(true);
|
||||
});
|
||||
|
||||
it("evaluateCommit returns ok=false with per-clause reasons on failure", () => {
|
||||
const badCtx = { ...passingCtx, dlt: { tx_hash: "not-hex" } };
|
||||
const r = evaluateCommit(terms, badCtx);
|
||||
expect(r.ok).toBe(false);
|
||||
const failing = r.results.find((x) => !x.ok);
|
||||
expect(failing?.clauseId).toBe("commit.dlt_tx_hash");
|
||||
expect(failing?.failureReason).toBeTruthy();
|
||||
});
|
||||
|
||||
it("evaluateAbort fires when an active exception exists", () => {
|
||||
const ctx = {
|
||||
...passingCtx,
|
||||
exceptions: { active: [{ kind: "timeout" }] },
|
||||
};
|
||||
const r = evaluateAbort(terms, ctx);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.results.find((x) => x.clauseId === "abort.exception_raised")?.ok).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("evaluateClauses surfaces evaluator errors without throwing", () => {
|
||||
const bogus = [
|
||||
{
|
||||
id: "bogus",
|
||||
description: "bad regex",
|
||||
binds: "both" as const,
|
||||
assert: { path: "h", op: "matches" as const, value: "[" }, // invalid regex
|
||||
},
|
||||
];
|
||||
const r = evaluateClauses(bogus, { h: "x" });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.results[0].failureReason).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildIssueInstrumentObligation()", () => {
|
||||
it("binds the instrument template hash into governingDocuments", () => {
|
||||
const terms = buildIssueInstrumentObligation({
|
||||
instrument,
|
||||
payor: "A",
|
||||
payee: "B",
|
||||
authorizedParticipants,
|
||||
});
|
||||
expect(terms.governingDocuments[0].templateHash).toBe(instrument.templateHash);
|
||||
expect(terms.governingDocuments[0].governingLaw).toBe("URDG 758");
|
||||
});
|
||||
|
||||
it("validates cleanly", () => {
|
||||
const terms = buildIssueInstrumentObligation({
|
||||
instrument,
|
||||
payor: "A",
|
||||
payee: "B",
|
||||
authorizedParticipants,
|
||||
});
|
||||
expect(validateObligationTerms(terms).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
245
orchestrator/tests/unit/rulesEngine.test.ts
Normal file
245
orchestrator/tests/unit/rulesEngine.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
169
orchestrator/tests/unit/swift.test.ts
Normal file
169
orchestrator/tests/unit/swift.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
@@ -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={
|
||||
|
||||
@@ -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' },
|
||||
|
||||
51
src/components/portal/StateMachineView.tsx
Normal file
51
src/components/portal/StateMachineView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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.',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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; }
|
||||
|
||||
243
src/pages/TransactionsPage.tsx
Normal file
243
src/pages/TransactionsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
281
src/services/orchestrator.ts
Normal file
281
src/services/orchestrator.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user