PR R: FIN-link sandbox service #22

Merged
nsatoshi merged 1 commits from devin/1776883027-pr-r-fin-sandbox into main 2026-04-22 20:30:47 +00:00
5 changed files with 561 additions and 0 deletions

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