PR Y: wire EXT-* blocker IDs into orchestrator mock-mode logs #28

Merged
nsatoshi merged 1 commits from devin/1776894844-pr-y-blocker-ids into main 2026-04-22 21:58:57 +00:00
7 changed files with 365 additions and 20 deletions

View File

@@ -0,0 +1,159 @@
/**
* External dependency blocker registry (EXT-* IDs).
*
* Mirrors the blocker gate in `proxmox/scripts/verify/
* check-external-dependencies.sh` so orchestrator startup logs and
* provider-switch mock-mode logs surface the **same** IDs the
* deployment pipeline already tracks. When operators see
* "[DbisCore] mock mode" they also see `blockerId: EXT-DBIS-CORE`,
* which maps 1:1 to the proxmox checker output.
*
* A blocker is considered **active** when:
* - the upstream dependency is not yet reachable / not yet built, AND
* - the orchestrator env does not point at any live instance (the
* presence of a "live URL" env var flips the blocker to resolved).
*
* Source of truth for the list: proxmox/docs/03-deployment/
* EXTERNAL_DEPENDENCY_BLOCKERS.md.
*/
export const EXT_BLOCKER_IDS = [
"EXT-DBIS-CORE",
"EXT-CC-PAYMENT-ADAPTERS",
"EXT-CC-AUDIT-LEDGER",
"EXT-CC-SHARED-EVENTS",
"EXT-CC-SHARED-SCHEMAS",
"EXT-FIN-GATEWAY",
"EXT-CHAIN138-CI-RPC",
] as const;
export type ExtBlockerId = (typeof EXT_BLOCKER_IDS)[number];
export interface ExtBlockerDetail {
id: ExtBlockerId;
title: string;
/** Env var whose presence resolves this blocker from the orchestrator's POV. */
resolvingEnvVar?: string;
/** Whether the blocker is structurally resolved independently of env. */
staticallyResolved?: boolean;
/** Short description suitable for structured logs. */
description: string;
}
export const BLOCKER_DETAILS: Record<ExtBlockerId, ExtBlockerDetail> = {
"EXT-DBIS-CORE": {
id: "EXT-DBIS-CORE",
title: "dbis_core live deployment",
resolvingEnvVar: "DBIS_CORE_URL",
description:
"DBIS Core Banking API not deployed; orchestrator falls back to deterministic mock.",
},
"EXT-CC-PAYMENT-ADAPTERS": {
id: "EXT-CC-PAYMENT-ADAPTERS",
title: "DBIS/cc-payment-adapters implementation",
description:
"Upstream repo is a template scaffold; no orchestrator client wired yet.",
},
"EXT-CC-AUDIT-LEDGER": {
id: "EXT-CC-AUDIT-LEDGER",
title: "DBIS/cc-audit-ledger implementation",
description:
"Upstream repo is a template scaffold; audit sink remains in-process events table.",
},
"EXT-CC-SHARED-EVENTS": {
id: "EXT-CC-SHARED-EVENTS",
title: "DBIS/cc-shared-events implementation",
description:
"Upstream repo is a template scaffold; orchestrator uses local eventBus schema.",
},
"EXT-CC-SHARED-SCHEMAS": {
id: "EXT-CC-SHARED-SCHEMAS",
title: "DBIS/cc-shared-schemas implementation",
description:
"Upstream repo is a template scaffold; orchestrator types are locally defined.",
},
"EXT-FIN-GATEWAY": {
id: "EXT-FIN-GATEWAY",
title: "Real FIN / Alliance Access gateway",
resolvingEnvVar: "FIN_SANDBOX_URL",
description:
"No real FIN transport; orchestrator routes dispatch through the in-process sandbox.",
},
"EXT-CHAIN138-CI-RPC": {
id: "EXT-CHAIN138-CI-RPC",
title: "Chain 138 RPC reachable from CI",
resolvingEnvVar: "CHAIN_138_RPC_URL",
description:
"Public Chain 138 RPC endpoint available; E2E and notary-chain paths can target a real chain.",
},
};
export type BlockerStatus = "active" | "resolved";
export interface BlockerStatusRecord extends ExtBlockerDetail {
status: BlockerStatus;
/** Value of the resolving env var at the time of evaluation, if any. */
resolvedVia?: string;
}
/**
* Evaluate current blocker status against `process.env` (or a
* supplied env object, for tests).
*/
export function evaluateBlockers(
env: NodeJS.ProcessEnv = process.env,
): BlockerStatusRecord[] {
return EXT_BLOCKER_IDS.map((id) => {
const detail = BLOCKER_DETAILS[id];
if (detail.staticallyResolved) {
return { ...detail, status: "resolved" };
}
if (detail.resolvingEnvVar) {
const v = env[detail.resolvingEnvVar];
if (v && v.length > 0) {
return {
...detail,
status: "resolved",
resolvedVia: detail.resolvingEnvVar,
};
}
}
return { ...detail, status: "active" };
});
}
/**
* Convenience: same as evaluateBlockers() filtered to active IDs only.
*/
export function activeBlockers(
env: NodeJS.ProcessEnv = process.env,
): ExtBlockerId[] {
return evaluateBlockers(env)
.filter((b) => b.status === "active")
.map((b) => b.id);
}
/**
* Emit a structured startup summary of external blockers using the
* supplied logger. Shape matches the proxmox checker output so
* operators can grep for the same IDs across the two systems.
*/
export function logBlockerStatusAtBoot(logger: {
info: (obj: Record<string, unknown>, msg: string) => void;
}): void {
const records = evaluateBlockers();
const active = records.filter((b) => b.status === "active").map((b) => b.id);
const resolved = records.filter((b) => b.status === "resolved").map((b) => b.id);
logger.info(
{
externalBlockers: records.map((b) => ({
id: b.id,
status: b.status,
resolvedVia: b.resolvedVia,
})),
activeCount: active.length,
resolvedCount: resolved.length,
},
`[ExternalBlockers] ${active.length} active, ${resolved.length} resolved`,
);
}

View File

@@ -2,6 +2,7 @@ import "dotenv/config";
import express from "express";
import cors from "cors";
import { validateEnv } from "./config/env";
import { logBlockerStatusAtBoot } from "./config/externalBlockers";
import {
apiLimiter,
securityHeaders,
@@ -23,6 +24,11 @@ import { runMigration } from "./db/migrations";
// Validate environment on startup
validateEnv();
// Surface the current EXT-* external-dependency blocker status so
// orchestrator startup logs match the proxmox deployment checker
// (proxmox/scripts/verify/check-external-dependencies.sh) 1:1.
logBlockerStatusAtBoot(logger);
const app = express();
const PORT = process.env.PORT || 8080;

View File

@@ -1,20 +1,44 @@
import type { Plan } from "../types/plan";
import { generatePacs008 } from "./iso20022";
import { logger } from "../logging/logger";
/**
* Bank-instruction client — two-phase-commit adapter for the payment
* leg (arch §4.3 Payment Messaging / Settlement Layer).
*
* Until `d-bis/dbis_core` is reachable as a live API, every call here
* is a deterministic mock. That corresponds to blocker EXT-DBIS-CORE
* in proxmox/docs/03-deployment/EXTERNAL_DEPENDENCY_BLOCKERS.md and
* flips to real once DBIS_CORE_URL is set (see services/dbisCore/).
*/
const BLOCKER_ID = "EXT-DBIS-CORE";
function bankMode(): "live" | "mock" {
return process.env.DBIS_CORE_URL ? "live" : "mock";
}
/**
* Prepare bank instruction (2PC prepare phase)
* Sends provisional ISO-20022 message
*/
export async function prepareBankInstruction(plan: Plan): Promise<boolean> {
console.log(`[Bank] Preparing instruction for plan ${plan.plan_id}`);
const mode = bankMode();
logger.info(
{
planId: plan.plan_id,
mode,
...(mode === "mock" ? { blockerId: BLOCKER_ID } : {}),
},
"[Bank] prepareBankInstruction()",
);
// Mock: In real implementation, this would:
// 1. Generate provisional ISO-20022 message (pacs.008 with conditional settlement)
// 2. Send to bank connector
// 3. Receive provisional acceptance
await new Promise((resolve) => setTimeout(resolve, 100));
return true;
}
@@ -27,30 +51,39 @@ export async function commitBankInstruction(plan: Plan): Promise<{
isoMessageId?: string;
error?: string;
}> {
console.log(`[Bank] Committing instruction for plan ${plan.plan_id}`);
const mode = bankMode();
logger.info(
{
planId: plan.plan_id,
mode,
...(mode === "mock" ? { blockerId: BLOCKER_ID } : {}),
},
"[Bank] commitBankInstruction()",
);
try {
// Generate final ISO-20022 message
const isoMessage = await generatePacs008(plan);
// Mock: In real implementation, this would:
// 1. Send ISO message to bank connector
// 2. Receive confirmation and message ID
// 3. Store message ID for audit trail
const isoMessageId = `MSG-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const isoMessageId = `MSG-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
// Simulate processing delay
await new Promise((resolve) => setTimeout(resolve, 300));
return {
success: true,
isoMessageId,
};
} catch (error: any) {
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
return {
success: false,
error: error.message,
error,
};
}
}
@@ -60,13 +93,20 @@ export async function commitBankInstruction(plan: Plan): Promise<{
* Cancels provisional instruction
*/
export async function abortBankInstruction(planId: string): Promise<void> {
console.log(`[Bank] Aborting instruction for plan ${planId}`);
const mode = bankMode();
logger.info(
{
planId,
mode,
...(mode === "mock" ? { blockerId: BLOCKER_ID } : {}),
},
"[Bank] abortBankInstruction()",
);
// Mock: In real implementation, this would:
// 1. Generate cancellation message (camt.056)
// 2. Send to bank connector
// 3. Confirm cancellation
await new Promise((resolve) => setTimeout(resolve, 100));
}

View File

@@ -149,7 +149,7 @@ export function createCcIdentityClient(
}
logger.info(
{ mode: "mock" },
"[CcIdentity] HTTP client (no CC_IDENTITY_URL — mock mode)",
"[CcIdentity] HTTP client (no CC_IDENTITY_URL — mock mode; upstream cc-identity-core ships code but not yet deployed)",
);
return new MockCcIdentityClient();
}

View File

@@ -210,6 +210,9 @@ export function createDbisCoreClient(
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)");
logger.info(
{ mode: "mock", blockerId: "EXT-DBIS-CORE" },
"[DbisCore] HTTP client (no DBIS_CORE_URL — mock mode; blocker EXT-DBIS-CORE active)",
);
return new MockDbisCoreClient();
}

View File

@@ -68,9 +68,27 @@ export async function createInProcessFinLinkClient(): Promise<FinLinkClient> {
/**
* Factory: returns an HTTP client if FIN_SANDBOX_URL is set, else an
* in-process client that short-circuits to the sandbox module.
*
* When falling back to the in-process sandbox we emit blocker
* EXT-FIN-GATEWAY (per proxmox/docs/03-deployment/
* EXTERNAL_DEPENDENCY_BLOCKERS.md) — that id maps 1:1 with the
* deployment checker and signals "no real FIN / Alliance Access
* transport configured yet".
*/
export async function getFinLinkClient(): Promise<FinLinkClient> {
const url = process.env.FIN_SANDBOX_URL;
if (url) return createHttpFinLinkClient(url);
if (url) {
const { logger } = await import("../../logging/logger");
logger.info(
{ baseUrl: url, mode: "live" },
"[FinLink] HTTP client (FIN_SANDBOX_URL)",
);
return createHttpFinLinkClient(url);
}
const { logger } = await import("../../logging/logger");
logger.info(
{ mode: "sandbox", blockerId: "EXT-FIN-GATEWAY" },
"[FinLink] in-process sandbox (no FIN_SANDBOX_URL — blocker EXT-FIN-GATEWAY active)",
);
return createInProcessFinLinkClient();
}

View File

@@ -0,0 +1,119 @@
/**
* Unit tests for the EXT-* external-dependency blocker registry.
* Headless — no network, no UI.
*/
import {
EXT_BLOCKER_IDS,
BLOCKER_DETAILS,
evaluateBlockers,
activeBlockers,
logBlockerStatusAtBoot,
} from "../../src/config/externalBlockers";
describe("externalBlockers registry", () => {
it("exposes exactly the 7 blocker IDs the proxmox checker tracks", () => {
expect(EXT_BLOCKER_IDS).toEqual([
"EXT-DBIS-CORE",
"EXT-CC-PAYMENT-ADAPTERS",
"EXT-CC-AUDIT-LEDGER",
"EXT-CC-SHARED-EVENTS",
"EXT-CC-SHARED-SCHEMAS",
"EXT-FIN-GATEWAY",
"EXT-CHAIN138-CI-RPC",
]);
});
it("has a detail record for every id", () => {
for (const id of EXT_BLOCKER_IDS) {
expect(BLOCKER_DETAILS[id]).toBeDefined();
expect(BLOCKER_DETAILS[id].id).toBe(id);
expect(BLOCKER_DETAILS[id].title.length).toBeGreaterThan(0);
expect(BLOCKER_DETAILS[id].description.length).toBeGreaterThan(0);
}
});
});
describe("evaluateBlockers()", () => {
it("marks everything active on an empty env", () => {
const records = evaluateBlockers({});
expect(records).toHaveLength(EXT_BLOCKER_IDS.length);
expect(records.every((r) => r.status === "active")).toBe(true);
});
it("resolves EXT-DBIS-CORE when DBIS_CORE_URL is set", () => {
const records = evaluateBlockers({ DBIS_CORE_URL: "http://x.test" });
const rec = records.find((r) => r.id === "EXT-DBIS-CORE");
expect(rec?.status).toBe("resolved");
expect(rec?.resolvedVia).toBe("DBIS_CORE_URL");
});
it("resolves EXT-FIN-GATEWAY when FIN_SANDBOX_URL is set", () => {
const records = evaluateBlockers({ FIN_SANDBOX_URL: "http://fin.test" });
expect(records.find((r) => r.id === "EXT-FIN-GATEWAY")?.status).toBe("resolved");
});
it("resolves EXT-CHAIN138-CI-RPC when CHAIN_138_RPC_URL is set", () => {
const records = evaluateBlockers({
CHAIN_138_RPC_URL: "https://rpc.public-0138.defi-oracle.io",
});
expect(records.find((r) => r.id === "EXT-CHAIN138-CI-RPC")?.status).toBe("resolved");
});
it("leaves cc-* scaffold blockers active regardless of env", () => {
const records = evaluateBlockers({
DBIS_CORE_URL: "http://x",
FIN_SANDBOX_URL: "http://y",
CHAIN_138_RPC_URL: "http://z",
});
const scaffoldIds = [
"EXT-CC-PAYMENT-ADAPTERS",
"EXT-CC-AUDIT-LEDGER",
"EXT-CC-SHARED-EVENTS",
"EXT-CC-SHARED-SCHEMAS",
];
for (const id of scaffoldIds) {
expect(records.find((r) => r.id === id)?.status).toBe("active");
}
});
it("treats empty-string env var as unset (not resolved)", () => {
const records = evaluateBlockers({ DBIS_CORE_URL: "" });
expect(records.find((r) => r.id === "EXT-DBIS-CORE")?.status).toBe("active");
});
});
describe("activeBlockers()", () => {
it("returns 7 when env is empty", () => {
expect(activeBlockers({})).toHaveLength(7);
});
it("returns 6 when Chain-138 RPC is resolved", () => {
const ids = activeBlockers({
CHAIN_138_RPC_URL: "https://rpc.public-0138.defi-oracle.io",
});
expect(ids).not.toContain("EXT-CHAIN138-CI-RPC");
expect(ids).toHaveLength(6);
});
});
describe("logBlockerStatusAtBoot()", () => {
it("emits a single summary with active + resolved counts", () => {
const calls: Array<{ obj: Record<string, unknown>; msg: string }> = [];
const fakeLogger = {
info: (obj: Record<string, unknown>, msg: string) => calls.push({ obj, msg }),
};
const prev = process.env.CHAIN_138_RPC_URL;
process.env.CHAIN_138_RPC_URL = "https://rpc.public-0138.defi-oracle.io";
try {
logBlockerStatusAtBoot(fakeLogger);
} finally {
if (prev === undefined) delete process.env.CHAIN_138_RPC_URL;
else process.env.CHAIN_138_RPC_URL = prev;
}
expect(calls).toHaveLength(1);
expect(calls[0].msg).toMatch(/active,.*resolved/);
expect((calls[0].obj.activeCount as number) + (calls[0].obj.resolvedCount as number)).toBe(7);
expect(calls[0].obj.resolvedCount).toBeGreaterThanOrEqual(1);
});
});