Compare commits

...

5 Commits

Author SHA1 Message Date
Devin AI
17745b3aea PR T: consolidate obligations/evaluator into rulesEngine
Some checks failed
CI / Frontend Lint (pull_request) Failing after 10s
CI / Frontend Type Check (pull_request) Failing after 6s
CI / Frontend Build (pull_request) Failing after 11s
CI / Frontend E2E Tests (pull_request) Failing after 9s
CI / Orchestrator Build (pull_request) Failing after 6s
CI / Orchestrator Unit Tests (pull_request) Failing after 5s
CI / Orchestrator E2E (Testcontainers) (pull_request) Has been skipped
CI / Contracts Compile (pull_request) Failing after 7s
CI / Contracts Test (pull_request) Failing after 6s
Code Quality / SonarQube Analysis (pull_request) Failing after 20s
Code Quality / Code Quality Checks (pull_request) Failing after 5s
Security Scan / Dependency Vulnerability Scan (pull_request) Failing after 4s
Security Scan / OWASP ZAP Scan (pull_request) Failing after 4s
Follow-up to PRs #20 (rules engine) and #23 (obligation layer) which
shipped an equivalent Condition shape. obligations/evaluator.ts is now
a thin compat wrapper re-exporting the shared Condition types and
delegating evaluateCondition() / resolvePath() to services/rulesEngine.
Preserves the historical resolvePath(path, context) signature used by
tests and keeps existing imports under services/obligations/evaluator
working unchanged.

Verified: npx tsc --noEmit clean; 10 suites / 128 tests pass.
Co-Authored-By: Nakamoto, S <defi@defi-oracle.io>
2026-04-22 20:47:23 +00:00
d7d3e80bff PR Q: E2E Testcontainers integration suite (#21)
Some checks failed
CI / Frontend Lint (push) Failing after 6s
CI / Frontend Type Check (push) Failing after 5s
CI / Frontend Build (push) Failing after 8s
CI / Frontend E2E Tests (push) Failing after 7s
CI / Orchestrator Build (push) Failing after 5s
CI / Orchestrator Unit Tests (push) Failing after 7s
CI / Orchestrator E2E (Testcontainers) (push) Failing after 6s
CI / Contracts Compile (push) Failing after 7s
CI / Contracts Test (push) Failing after 5s
Security Scan / Dependency Vulnerability Scan (push) Failing after 3s
Security Scan / OWASP ZAP Scan (push) Failing after 4s
2026-04-22 20:31:06 +00:00
2c72a51a06 PR R: FIN-link sandbox service (#22)
Some checks failed
CI / Frontend Lint (push) Has been cancelled
CI / Frontend Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Frontend E2E Tests (push) Has been cancelled
CI / Orchestrator Build (push) Has been cancelled
CI / Contracts Compile (push) Has been cancelled
CI / Contracts Test (push) Has been cancelled
Security Scan / OWASP ZAP Scan (push) Has been cancelled
Security Scan / Dependency Vulnerability Scan (push) Has been cancelled
2026-04-22 20:30:45 +00:00
b77ebce497 PR S: Machine-form obligation layer (terms-as-data) (#23)
Some checks failed
CI / Frontend Lint (push) Has been cancelled
CI / Frontend Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Frontend E2E Tests (push) Has been cancelled
CI / Orchestrator Build (push) Has been cancelled
CI / Contracts Compile (push) Has been cancelled
CI / Contracts Test (push) Has been cancelled
Security Scan / Dependency Vulnerability Scan (push) Has been cancelled
Security Scan / OWASP ZAP Scan (push) Has been cancelled
2026-04-22 20:30:32 +00:00
351bb472b6 PR P: Pluggable Rules Engine (JSON DSL) (#20)
Some checks failed
CI / Frontend Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Frontend E2E Tests (push) Has been cancelled
CI / Orchestrator Build (push) Failing after 10s
CI / Frontend Lint (push) Has been cancelled
Security Scan / Dependency Vulnerability Scan (push) Has been cancelled
Security Scan / OWASP ZAP Scan (push) Has been cancelled
CI / Contracts Compile (push) Failing after 8s
CI / Contracts Test (push) Failing after 7s
2026-04-22 20:30:21 +00:00
16 changed files with 2140 additions and 1 deletions

View File

@@ -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

View File

@@ -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"],
};

View 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,
};

View File

@@ -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"

View File

@@ -112,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";

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

View 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";

View 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;
}
}

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

View 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,
},
],
};
}

View 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[];
}

View 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;
}

View File

@@ -0,0 +1,178 @@
/**
* E2E transaction lifecycle (gap-analysis v2 §7.8 / §10.8).
*
* Brings up:
* - Postgres via @testcontainers/postgresql
* - All migrations 001006 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);
});
});

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

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

View File

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