PR R: FIN-link sandbox service #22
@@ -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";
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user