From 7c5dd145d6eea29be4f5f19a8be9da23a12248f5 Mon Sep 17 00:00:00 2001 From: Devin Date: Wed, 22 Apr 2026 18:39:31 +0000 Subject: [PATCH] FIN-link sandbox service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes gap-analysis v2 §7.1 (no FIN-link adapter; SWIFT generators produce strings but no transport) + §10.6 (stand up sandbox transport). - services/finLink/sandbox.ts — in-process FIN-link sandbox. Accepts POST /dispatch with a SWIFT/ISO payload, assigns a FIN reference, and advances messages deterministically through received -> acknowledged -> accepted -> settled (with reject as a terminal fork). Optional webhook per-message (x-fin-sandbox-signature header, HMAC-SHA256). Timer-driven auto-progress opt-in via startAutoProgress(). - services/finLink/client.ts — two client adapters: createHttpFinLinkClient(baseUrl) - for the live router createInProcessFinLinkClient() - for unit tests that skip the HTTP hop getFinLinkClient() picks HTTP when FIN_SANDBOX_URL is set, else falls back to in-process. - services/finLink/index.ts — public surface. - src/index.ts — mounts /fin-sandbox only when FIN_SANDBOX_ENABLED=true; off by default to keep prod surface clean. - tests/unit/finLinkSandbox.test.ts — 12 tests covering lifecycle, rejection, listing, signature determinism, HTTP endpoints (dispatch/advance/messages/filtering), and both client adapters (including a live ephemeral-port HTTP round-trip). - Verification: tsc --noEmit clean; full jest 92/92 passing (8 suites). --- orchestrator/src/index.ts | 13 + orchestrator/src/services/finLink/client.ts | 76 +++++ orchestrator/src/services/finLink/index.ts | 28 ++ orchestrator/src/services/finLink/sandbox.ts | 274 ++++++++++++++++++ .../tests/unit/finLinkSandbox.test.ts | 170 +++++++++++ 5 files changed, 561 insertions(+) create mode 100644 orchestrator/src/services/finLink/client.ts create mode 100644 orchestrator/src/services/finLink/index.ts create mode 100644 orchestrator/src/services/finLink/sandbox.ts create mode 100644 orchestrator/tests/unit/finLinkSandbox.test.ts diff --git a/orchestrator/src/index.ts b/orchestrator/src/index.ts index c7ccdbd..26f410c 100644 --- a/orchestrator/src/index.ts +++ b/orchestrator/src/index.ts @@ -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"; diff --git a/orchestrator/src/services/finLink/client.ts b/orchestrator/src/services/finLink/client.ts new file mode 100644 index 0000000..a90d1b3 --- /dev/null +++ b/orchestrator/src/services/finLink/client.ts @@ -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; + getMessage(reference: string): Promise; +} + +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 { + 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 { + const url = process.env.FIN_SANDBOX_URL; + if (url) return createHttpFinLinkClient(url); + return createInProcessFinLinkClient(); +} diff --git a/orchestrator/src/services/finLink/index.ts b/orchestrator/src/services/finLink/index.ts new file mode 100644 index 0000000..da5f06c --- /dev/null +++ b/orchestrator/src/services/finLink/index.ts @@ -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"; diff --git a/orchestrator/src/services/finLink/sandbox.ts b/orchestrator/src/services/finLink/sandbox.ts new file mode 100644 index 0000000..470ea36 --- /dev/null +++ b/orchestrator/src/services/finLink/sandbox.ts @@ -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(); + +// 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 { + 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 { + 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; + 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; + } +} diff --git a/orchestrator/tests/unit/finLinkSandbox.test.ts b/orchestrator/tests/unit/finLinkSandbox.test.ts new file mode 100644 index 0000000..395363b --- /dev/null +++ b/orchestrator/tests/unit/finLinkSandbox.test.ts @@ -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: "" }); + 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: "" }) + .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: "", + 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(); + } + }); + }); +}); -- 2.34.1