PR #26 (squash-merged via Gitea API)
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 / Orchestrator Unit Tests (push) Has been cancelled
CI / Orchestrator E2E (Testcontainers) (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

This commit was merged in pull request #26.
This commit is contained in:
2026-04-22 21:11:56 +00:00
parent a9fbb39889
commit 7fdc9c06da
4 changed files with 528 additions and 0 deletions

View File

@@ -0,0 +1,215 @@
/**
* HTTP client adapter for `d-bis/dbis_core`.
*
* Provider-switched: when `DBIS_CORE_URL` is set the client makes real
* HTTP calls to the upstream DBIS Core Banking API; otherwise every
* method returns a deterministic mock response so unit tests, local
* dev, and CI still work.
*
* This is intentionally minimal — only the endpoints the orchestrator
* actually calls from its settlement / obligation / compliance paths.
* Extend the client surface as new orchestrator capabilities need more
* of the dbis_core API.
*/
import { logger } from "../../logging/logger";
import type {
AccountBalance,
AriDecisionRequest,
AriDecisionResponse,
AtomicSettleRequest,
AtomicSettleResponse,
Pacs008DispatchRequest,
Pacs008DispatchResponse,
RouteRequest,
RouteResponse,
SettlementStatus,
} from "./types";
export interface DbisCoreConfig {
baseUrl?: string;
apiKey?: string;
timeoutMs?: number;
fetchImpl?: typeof fetch;
}
export interface DbisCoreClient {
mode: "live" | "mock";
getAccountBalance(accountId: string): Promise<AccountBalance>;
findSettlementRoute(req: RouteRequest): Promise<RouteResponse>;
atomicSettle(req: AtomicSettleRequest): Promise<AtomicSettleResponse>;
getSettlementStatus(settlementId: string): Promise<SettlementStatus>;
requestAriDecision(req: AriDecisionRequest): Promise<AriDecisionResponse>;
dispatchPacs008(req: Pacs008DispatchRequest): Promise<Pacs008DispatchResponse>;
}
function loadConfigFromEnv(): DbisCoreConfig {
return {
baseUrl: process.env.DBIS_CORE_URL,
apiKey: process.env.DBIS_CORE_API_KEY,
timeoutMs: process.env.DBIS_CORE_TIMEOUT_MS
? parseInt(process.env.DBIS_CORE_TIMEOUT_MS, 10)
: 10_000,
};
}
class HttpDbisCoreClient implements DbisCoreClient {
readonly mode = "live" as const;
private readonly baseUrl: string;
private readonly apiKey?: string;
private readonly timeoutMs: number;
private readonly fetchImpl: typeof fetch;
constructor(cfg: Required<Pick<DbisCoreConfig, "baseUrl">> & DbisCoreConfig) {
this.baseUrl = cfg.baseUrl.replace(/\/+$/, "");
this.apiKey = cfg.apiKey;
this.timeoutMs = cfg.timeoutMs ?? 10_000;
this.fetchImpl = cfg.fetchImpl ?? fetch;
}
private async request<T>(
method: "GET" | "POST",
path: string,
body?: unknown,
): Promise<T> {
const url = `${this.baseUrl}${path}`;
const headers: Record<string, string> = {
Accept: "application/json",
};
if (body !== undefined) headers["Content-Type"] = "application/json";
if (this.apiKey) headers["X-API-Key"] = this.apiKey;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const resp = await this.fetchImpl(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
if (!resp.ok) {
const text = await resp.text().catch(() => "");
throw new Error(
`dbis_core ${method} ${path} failed: HTTP ${resp.status} ${text.slice(0, 200)}`,
);
}
return (await resp.json()) as T;
} finally {
clearTimeout(timer);
}
}
getAccountBalance(accountId: string): Promise<AccountBalance> {
return this.request<AccountBalance>(
"GET",
`/api/accounts/${encodeURIComponent(accountId)}/balance`,
);
}
findSettlementRoute(req: RouteRequest): Promise<RouteResponse> {
return this.request<RouteResponse>("POST", "/api/isn/route", req);
}
atomicSettle(req: AtomicSettleRequest): Promise<AtomicSettleResponse> {
return this.request<AtomicSettleResponse>("POST", "/api/isn/atomic", req);
}
getSettlementStatus(settlementId: string): Promise<SettlementStatus> {
return this.request<SettlementStatus>(
"GET",
`/api/isn/settlements/${encodeURIComponent(settlementId)}`,
);
}
requestAriDecision(req: AriDecisionRequest): Promise<AriDecisionResponse> {
return this.request<AriDecisionResponse>("POST", "/api/ari/decision", req);
}
dispatchPacs008(req: Pacs008DispatchRequest): Promise<Pacs008DispatchResponse> {
return this.request<Pacs008DispatchResponse>(
"POST",
"/api/v1/gpn/message/pacs008",
req,
);
}
}
class MockDbisCoreClient implements DbisCoreClient {
readonly mode = "mock" as const;
async getAccountBalance(accountId: string): Promise<AccountBalance> {
return {
accountId,
currency: "USD",
available: "1000000.00",
held: "0.00",
asOf: new Date().toISOString(),
};
}
async findSettlementRoute(req: RouteRequest): Promise<RouteResponse> {
return {
routeId: `mock-route-${req.sourceBankId}-${req.destinationBankId}`,
hops: [
{ bankId: req.sourceBankId, latencyMs: 20, feeBps: 0 },
{ bankId: req.destinationBankId, latencyMs: 40, feeBps: 5 },
],
estimatedLatencyMs: 60,
estimatedFeeBps: 5,
};
}
async atomicSettle(req: AtomicSettleRequest): Promise<AtomicSettleResponse> {
return {
settlementId: `mock-stlm-${req.reference}`,
status: "settled",
completedAt: new Date().toISOString(),
};
}
async getSettlementStatus(settlementId: string): Promise<SettlementStatus> {
return {
settlementId,
status: "settled",
legs: [
{ legId: `${settlementId}-leg1`, bankId: "mock-src", status: "confirmed" },
{ legId: `${settlementId}-leg2`, bankId: "mock-dst", status: "confirmed" },
],
lastUpdated: new Date().toISOString(),
};
}
async requestAriDecision(req: AriDecisionRequest): Promise<AriDecisionResponse> {
return {
txId: req.txId,
outcome: "allow",
riskScore: 0.1,
reasons: ["mock: default allow"],
evaluatedAt: new Date().toISOString(),
};
}
async dispatchPacs008(req: Pacs008DispatchRequest): Promise<Pacs008DispatchResponse> {
return {
messageId: req.messageId,
status: "accepted",
acknowledgmentRef: `mock-ack-${req.messageId}`,
};
}
}
/**
* Factory. Call once per process (or per test run) to get a client
* wired to whichever backend the env selects.
*/
export function createDbisCoreClient(
cfg: DbisCoreConfig = loadConfigFromEnv(),
): DbisCoreClient {
if (cfg.baseUrl) {
logger.info({ baseUrl: cfg.baseUrl, mode: "live" }, "[DbisCore] HTTP client");
return new HttpDbisCoreClient({ ...cfg, baseUrl: cfg.baseUrl });
}
logger.info({ mode: "mock" }, "[DbisCore] HTTP client (no DBIS_CORE_URL — mock mode)");
return new MockDbisCoreClient();
}

View File

@@ -0,0 +1,9 @@
/**
* Public surface for the dbis_core client adapter.
* See ./client.ts for implementation and ./types.ts for the shared
* request/response shapes.
*/
export * from "./types";
export { createDbisCoreClient } from "./client";
export type { DbisCoreClient, DbisCoreConfig } from "./client";

View File

@@ -0,0 +1,106 @@
/**
* Canonical request/response shapes for the subset of `d-bis/dbis_core`
* endpoints the orchestrator actually calls. Kept small and focused —
* this is a client adapter, not a mirror of the upstream service.
*
* Upstream endpoint references (from dbis_core/src/integration/api-
* gateway/app.ts mount points):
*
* GET /api/accounts/:accountId/balance
* POST /api/isn/route
* POST /api/isn/atomic
* POST /api/ari/decision
* POST /api/v1/gpn/message/pacs008
* GET /api/isn/settlements/:settlementId
*/
export interface AccountBalance {
accountId: string;
currency: string;
available: string;
held: string;
asOf: string;
}
export interface RouteRequest {
sourceBankId: string;
destinationBankId: string;
amount: string;
currencyCode: string;
}
export interface SettlementHop {
bankId: string;
latencyMs: number;
feeBps: number;
}
export interface RouteResponse {
routeId: string;
hops: SettlementHop[];
estimatedLatencyMs: number;
estimatedFeeBps: number;
}
export interface AtomicSettleRequest {
routeId: string;
sourceAccountId: string;
destinationAccountId: string;
amount: string;
currencyCode: string;
reference: string;
}
export interface AtomicSettleResponse {
settlementId: string;
status: "accepted" | "settled" | "rejected";
completedAt?: string;
rejectionReason?: string;
}
export interface AriDecisionRequest {
txId: string;
amount: string;
currencyCode: string;
creator: string;
counterparty?: string;
metadata?: Record<string, string | number | boolean>;
}
export type AriOutcome = "allow" | "deny" | "review";
export interface AriDecisionResponse {
txId: string;
outcome: AriOutcome;
riskScore: number;
reasons: string[];
evaluatedAt: string;
}
export interface Pacs008DispatchRequest {
messageId: string;
creationDateTime: string;
debtor: { name: string; bic: string; account: string };
creditor: { name: string; bic: string; account: string };
amount: string;
currencyCode: string;
remittanceInfo?: string;
}
export interface Pacs008DispatchResponse {
messageId: string;
status: "accepted" | "rejected";
acknowledgmentRef?: string;
rejectionReason?: string;
}
export interface SettlementStatus {
settlementId: string;
status: "pending" | "routing" | "executing" | "settled" | "failed" | "reversed";
legs: {
legId: string;
bankId: string;
status: "pending" | "dispatched" | "confirmed" | "failed";
}[];
lastUpdated: string;
}