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
275 lines
7.8 KiB
TypeScript
275 lines
7.8 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|
|
}
|