feat: SolaceNet gateway rails, IRU marketplace hardening, and docs

- Gateway adapter registry, rails routes, optional SOLACENET_GATEWAY_RAILS_ENFORCE; HTTP integration tests.
- IRU marketplace: rate limits, public routes, notifications/SMTP env docs; marketplace UI constants and flows.
- Quantum proxy legacy protocol types; debank/tezos/GSDS touch-ups; .env.example operator notes.
- SolaceNet doc set (gaps, runbooks, telecom schema example).

Tests: npm run test:iru-marketplace, npm run test:gateway (pass).
Note: full-repo tsc still reports unrelated legacy errors outside this change set.
Made-with: Cursor
This commit is contained in:
defiQUG
2026-04-07 23:21:55 -07:00
parent 1190476b0a
commit 6ebf71dda8
75 changed files with 4104 additions and 338 deletions

View File

@@ -0,0 +1,2 @@
/** Loaded before gateway HTTP integration tests (imports run before test body). */
process.env.GATEWAY_RAIL_RATE_LIMIT_IN_TEST = '1';

View File

@@ -0,0 +1,72 @@
/**
* Gateway rail HTTP surface — list, health, validate, enforcement hook (supertest).
* GATEWAY_RAIL_RATE_LIMIT_IN_TEST: see gateway-http-env-setup.ts (jest.gateway-http.config.js).
*/
import request from 'supertest';
import { DbisError, ErrorCode } from '@/shared/types';
import * as gatewayEnforcement from '@/core/gateway/rails/gateway-rails-enforcement';
import { createGatewayHttpTestApp } from '@/__tests__/utils/gateway-http-test-app';
const app = createGatewayHttpTestApp();
const BASE = '/api/v1/gateway';
describe('Gateway rails HTTP', () => {
const prevEnforce = process.env.SOLACENET_GATEWAY_RAILS_ENFORCE;
afterEach(() => {
process.env.SOLACENET_GATEWAY_RAILS_ENFORCE = prevEnforce;
jest.restoreAllMocks();
});
it('GET /rails returns maintainer, adapters, enforcement flag', async () => {
const res = await request(app).get(`${BASE}/rails`);
expect(res.status).toBe(200);
expect(res.body.maintainer).toBe('SolaceNet');
expect(Array.isArray(res.body.adapters)).toBe(true);
expect(res.body.adapters).toContain('dbis.adapter.ktt-evidence');
expect(typeof res.body.enforcementEnabled).toBe('boolean');
});
it('GET /rails/:adapterId/health returns 404 for unknown adapter', async () => {
const res = await request(app).get(`${BASE}/rails/not-a-real-adapter/health`);
expect(res.status).toBe(404);
expect(res.body.error).toBe('UNKNOWN_ADAPTER');
});
it('GET /rails/dbis.adapter.ktt-evidence/health returns 200', async () => {
const res = await request(app).get(`${BASE}/rails/dbis.adapter.ktt-evidence/health`);
expect(res.status).toBe(200);
expect(res.body.adapterId).toBe('dbis.adapter.ktt-evidence');
});
it('POST /rails/dbis.adapter.ktt-evidence/validate returns validation shape', async () => {
const res = await request(app)
.post(`${BASE}/rails/dbis.adapter.ktt-evidence/validate`)
.send({ tenantId: 't1', canonicalInstruction: {} });
expect(res.status).toBe(200);
expect(res.body.adapterId).toBe('dbis.adapter.ktt-evidence');
expect(res.body).toHaveProperty('ok');
});
it('returns 403 when enforcement denies capability', async () => {
process.env.SOLACENET_GATEWAY_RAILS_ENFORCE = '1';
jest.spyOn(gatewayEnforcement, 'maybeRequireGatewayMicroservices').mockRejectedValue(
new DbisError(ErrorCode.FORBIDDEN, 'Capability gateway-microservices is not available'),
);
const res = await request(app).get(`${BASE}/rails/dbis.adapter.ktt-evidence/health`);
expect(res.status).toBe(403);
expect(res.body.code).toBe(ErrorCode.FORBIDDEN);
});
it('POST /instructions returns 202 and correlation fields', async () => {
const res = await request(app)
.post(`${BASE}/instructions`)
.send({ tenantId: 't1', amount: '1', currency: 'USD' });
expect(res.status).toBe(202);
expect(res.body.status).toBe('RECEIVED');
expect(res.body.txnId).toBeDefined();
expect(res.body.correlationId).toBeDefined();
});
});

View File

@@ -0,0 +1,110 @@
/**
* IRU marketplace HTTP wiring: validation, pricing JSON, public inquiry shape (service spies; no DB).
*/
import request from 'supertest';
import { createIruMarketplaceHttpTestApp } from '@/__tests__/utils/iru-marketplace-http-test-app';
import { marketplaceService } from '@/core/iru/marketplace.service';
process.env.IRU_MARKETPLACE_RATE_LIMIT_IN_TEST = '1';
const app = createIruMarketplaceHttpTestApp();
const BASE = '/api/v1/iru/marketplace';
const validInquiryBody = {
offeringId: 'IRU-OFF-TEST',
organizationName: 'Test Reserve',
institutionalType: 'CentralBank',
jurisdiction: 'US',
contactEmail: 'ops@example.org',
contactName: 'Test User',
};
describe('IRU marketplace HTTP', () => {
let submitSpy: jest.SpyInstance;
let publicStatusSpy: jest.SpyInstance;
let pricingSpy: jest.SpyInstance;
beforeAll(() => {
submitSpy = jest.spyOn(marketplaceService, 'submitInquiry').mockResolvedValue({
inquiryId: 'INQ-MOCK',
status: 'submitted',
message: 'ok',
});
publicStatusSpy = jest.spyOn(marketplaceService, 'getInquiryStatusPublic').mockResolvedValue({
inquiryId: 'INQ-PUB',
status: 'submitted',
offering: { name: 'Tier 1', capacityTier: 1 },
submittedAt: new Date('2026-01-01T00:00:00.000Z'),
acknowledgedAt: null,
reviewedAt: null,
completedAt: null,
});
pricingSpy = jest.spyOn(marketplaceService, 'calculatePricing').mockResolvedValue({
offeringId: 'IRU-OFF-TEST',
capacityTier: 1,
basePrice: 100,
currency: 'USD',
pricingModel: 'Fixed',
});
});
afterAll(() => {
submitSpy.mockRestore();
publicStatusSpy.mockRestore();
pricingSpy.mockRestore();
});
beforeEach(() => {
submitSpy.mockClear();
publicStatusSpy.mockClear();
pricingSpy.mockClear();
});
it('POST /inquiries returns 201 and strips validation before service', async () => {
const res = await request(app).post(`${BASE}/inquiries`).send(validInquiryBody);
expect(res.status).toBe(201);
expect(submitSpy).toHaveBeenCalledTimes(1);
expect(submitSpy.mock.calls[0][0]).toMatchObject({
offeringId: 'IRU-OFF-TEST',
contactEmail: 'ops@example.org',
});
});
it('POST /inquiries returns 400 when jurisdiction is not 2 letters', async () => {
const res = await request(app)
.post(`${BASE}/inquiries`)
.send({ ...validInquiryBody, jurisdiction: 'USA' });
expect(res.status).toBe(400);
expect(submitSpy).not.toHaveBeenCalled();
});
it('GET /offerings/:id/pricing returns 400 for invalid usageProfile JSON', async () => {
const res = await request(app)
.get(`${BASE}/offerings/IRU-OFF-TEST/pricing`)
.query({ usageProfile: 'not-json{' });
expect(res.status).toBe(400);
expect(res.body.error?.code).toBe('INVALID_JSON');
expect(pricingSpy).not.toHaveBeenCalled();
});
it('GET /offerings/:id/pricing hits calculatePricing when JSON is valid', async () => {
const res = await request(app)
.get(`${BASE}/offerings/IRU-OFF-TEST/pricing`)
.query({ usageProfile: JSON.stringify({ tier: 1 }) });
expect(res.status).toBe(200);
expect(pricingSpy).toHaveBeenCalledWith('IRU-OFF-TEST', { tier: 1 });
});
it('GET /inquiries/:inquiryId returns public shape via getInquiryStatusPublic', async () => {
const res = await request(app).get(`${BASE}/inquiries/INQ-PUB`);
expect(res.status).toBe(200);
expect(publicStatusSpy).toHaveBeenCalledWith('INQ-PUB');
expect(res.body.data).toMatchObject({
inquiryId: 'INQ-PUB',
offering: { name: 'Tier 1', capacityTier: 1 },
});
expect(res.body.data).not.toHaveProperty('organizationName');
expect(res.body.data).not.toHaveProperty('notes');
});
});

View File

@@ -70,10 +70,10 @@ describe('MarketplaceService', () => {
(prisma.iruOffering.findUnique as jest.Mock).mockResolvedValue(mockOffering);
(prisma.iruInquiry.findFirst as jest.Mock).mockResolvedValue(null);
(prisma.iruInquiry.create as jest.Mock).mockResolvedValue({
inquiryId: 'INQ-12345678',
(prisma.iruInquiry.create as jest.Mock).mockImplementation(async ({ data }: { data: Record<string, unknown> }) => ({
...data,
status: 'submitted',
});
}));
const result = await marketplaceService.submitInquiry({
offeringId: 'IRU-OFF-001',
@@ -84,8 +84,16 @@ describe('MarketplaceService', () => {
contactName: 'Test User',
});
expect(result.inquiryId).toBe('INQ-12345678');
expect(result.inquiryId).toMatch(/^INQ-[0-9A-F]{32}$/);
expect(result.status).toBe('submitted');
expect(prisma.iruInquiry.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
offeringId: '1',
inquiryId: expect.stringMatching(/^INQ-[0-9A-F]{32}$/),
}),
})
);
});
it('should reject inquiry for inactive offering', async () => {
@@ -109,4 +117,35 @@ describe('MarketplaceService', () => {
).rejects.toThrow('is not active');
});
});
describe('getInquiryStatusPublic', () => {
it('omits PII and internal fields', async () => {
const submittedAt = new Date('2026-01-01T00:00:00.000Z');
(prisma.iruInquiry.findUnique as jest.Mock).mockResolvedValue({
inquiryId: 'INQ-PUB',
status: 'submitted',
organizationName: 'Secret',
contactEmail: 'x@y.com',
notes: 'internal',
qualificationResult: {},
riskScore: 1,
submittedAt,
acknowledgedAt: null,
reviewedAt: null,
completedAt: null,
offering: { name: 'T1', capacityTier: 1 },
});
const row = await marketplaceService.getInquiryStatusPublic('INQ-PUB');
expect(row).toEqual({
inquiryId: 'INQ-PUB',
status: 'submitted',
offering: { name: 'T1', capacityTier: 1 },
submittedAt,
acknowledgedAt: null,
reviewedAt: null,
completedAt: null,
});
});
});
});

View File

@@ -15,8 +15,6 @@ jest.setTimeout(10000);
// Cleanup after all tests
afterAll(async () => {
// Close any open connections
const prisma = new PrismaClient();
await prisma.$disconnect();
await prisma.$disconnect().catch(() => undefined);
});

View File

@@ -0,0 +1,63 @@
jest.mock('@/core/gateway/adapters/thirdweb/thirdweb-adapter', () => ({
ThirdwebAdapter: class ThirdwebAdapter {
async initialize() {}
async health() {
return { status: 'UP' as const };
}
async capabilities() {
return [];
}
async validate() {
return { ok: true };
}
async send() {
return { status: 'SENT' as const, railMessageId: 'mock' };
}
async receive() {
return { status: 'ACK' as const, railMessageId: 'mock' };
}
mapStatus(rail: { code: string; description?: string; source?: string }) {
return { status: 'IN_PROGRESS', railStatus: rail };
}
finalityHints() {
return {};
}
errorMap(railError: { code: string; message?: string }) {
return { errorCode: railError.code || 'UNKNOWN', retryClass: 'MANUAL_REVIEW' as const };
}
},
}));
import {
createGatewayRailAdapter,
GATEWAY_RAIL_ADAPTER_IDS,
isGatewayRailAdapterId,
listGatewayRailAdapterIds,
} from '@/core/gateway/adapters/gateway-adapter-registry';
describe('gateway-adapter-registry', () => {
it('exports a stable ordered list of rail adapter IDs', () => {
const ids = listGatewayRailAdapterIds();
expect(ids.length).toBe(GATEWAY_RAIL_ADAPTER_IDS.length);
expect(ids).toEqual([...GATEWAY_RAIL_ADAPTER_IDS]);
});
it('recognizes known adapter IDs', () => {
expect(isGatewayRailAdapterId('dbis.adapter.swift-fin')).toBe(true);
expect(isGatewayRailAdapterId('dbis.adapter.ktt-evidence')).toBe(true);
expect(isGatewayRailAdapterId('unknown')).toBe(false);
});
it('creates adapter instances for each registered id', () => {
for (const id of listGatewayRailAdapterIds()) {
const adapter = createGatewayRailAdapter(id);
expect(adapter).toBeDefined();
expect(typeof adapter!.validate).toBe('function');
expect(typeof adapter!.health).toBe('function');
}
});
it('returns undefined for unknown id', () => {
expect(createGatewayRailAdapter('not-a-rail')).toBeUndefined();
});
});

View File

@@ -0,0 +1,119 @@
jest.mock('@/shared/solacenet/sdk', () => ({
requireCapability: jest.fn().mockResolvedValue(undefined),
checkCapability: jest.fn(),
getCapabilityState: jest.fn(),
}));
import type { Request } from 'express';
import {
gatewayRailsEnforcementEnabled,
maybeRequireGatewayMicroservices,
requireGatewayMicroservicesForWorker,
resolveGatewayTenantId,
} from '@/core/gateway/rails/gateway-rails-enforcement';
import { requireCapability } from '@/shared/solacenet/sdk';
import { logger } from '@/infrastructure/monitoring/logger';
import { DbisError, ErrorCode } from '@/shared/types';
const mockRequireCapability = requireCapability as jest.MockedFunction<typeof requireCapability>;
describe('gateway-rails-enforcement', () => {
const origEnforce = process.env.SOLACENET_GATEWAY_RAILS_ENFORCE;
const origTenant = process.env.SOLACENET_DEFAULT_TENANT_ID;
let logSpy: jest.SpyInstance;
beforeAll(() => {
logSpy = jest.spyOn(logger, 'info').mockImplementation(() => logger as never);
});
afterAll(() => {
logSpy.mockRestore();
});
afterEach(() => {
process.env.SOLACENET_GATEWAY_RAILS_ENFORCE = origEnforce;
process.env.SOLACENET_DEFAULT_TENANT_ID = origTenant;
});
it('is disabled when env unset or not 1/true', () => {
delete process.env.SOLACENET_GATEWAY_RAILS_ENFORCE;
expect(gatewayRailsEnforcementEnabled()).toBe(false);
process.env.SOLACENET_GATEWAY_RAILS_ENFORCE = '0';
expect(gatewayRailsEnforcementEnabled()).toBe(false);
});
it('is enabled for 1 or true', () => {
process.env.SOLACENET_GATEWAY_RAILS_ENFORCE = '1';
expect(gatewayRailsEnforcementEnabled()).toBe(true);
process.env.SOLACENET_GATEWAY_RAILS_ENFORCE = 'true';
expect(gatewayRailsEnforcementEnabled()).toBe(true);
});
it('resolves tenant from body, header, env, then system', () => {
const req = { body: { tenantId: 't-body' }, headers: {} } as Request;
expect(resolveGatewayTenantId(req)).toBe('t-body');
const req2 = { body: {}, headers: { 'x-tenant-id': 't-header' } } as unknown as Request;
expect(resolveGatewayTenantId(req2)).toBe('t-header');
delete process.env.SOLACENET_DEFAULT_TENANT_ID;
const req3 = { body: {}, headers: {} } as Request;
expect(resolveGatewayTenantId(req3)).toBe('system');
process.env.SOLACENET_DEFAULT_TENANT_ID = 't-env';
expect(resolveGatewayTenantId(req3)).toBe('t-env');
});
it('maybeRequireGatewayMicroservices calls requireCapability when enforcement on', async () => {
process.env.SOLACENET_GATEWAY_RAILS_ENFORCE = '1';
mockRequireCapability.mockClear();
const req = { body: { tenantId: 'acme' }, headers: {} } as Request;
await maybeRequireGatewayMicroservices(req);
expect(mockRequireCapability).toHaveBeenCalledWith(
'gateway-microservices',
expect.objectContaining({ tenantId: 'acme', capabilityId: 'gateway-microservices' }),
);
});
it('maybeRequireGatewayMicroservices skips when enforcement off', async () => {
process.env.SOLACENET_GATEWAY_RAILS_ENFORCE = '0';
mockRequireCapability.mockClear();
await maybeRequireGatewayMicroservices({ body: {}, headers: {} } as Request);
expect(mockRequireCapability).not.toHaveBeenCalled();
});
it('maybeRequireGatewayMicroservices logs deny and rethrows on DbisError', async () => {
process.env.SOLACENET_GATEWAY_RAILS_ENFORCE = '1';
mockRequireCapability.mockRejectedValueOnce(new DbisError(ErrorCode.FORBIDDEN, 'denied'));
const req = {
body: { tenantId: 'acme' },
headers: {},
params: { adapterId: 'dbis.adapter.ktt-evidence' },
path: '/rails/dbis.adapter.ktt-evidence/health',
method: 'GET',
} as unknown as Request;
await expect(maybeRequireGatewayMicroservices(req)).rejects.toThrow(DbisError);
expect(logSpy).toHaveBeenCalledWith(
'Gateway rails enforcement audit',
expect.objectContaining({
decision: 'deny',
tenantId: 'acme',
adapterId: 'dbis.adapter.ktt-evidence',
}),
);
});
it('requireGatewayMicroservicesForWorker allows when capability ok', async () => {
process.env.SOLACENET_GATEWAY_RAILS_ENFORCE = '1';
mockRequireCapability.mockResolvedValueOnce(undefined);
await requireGatewayMicroservicesForWorker({
tenantId: 't1',
ingressKind: 'mq',
detail: 'queue:gateway.in',
});
expect(mockRequireCapability).toHaveBeenCalledWith(
'gateway-microservices',
expect.objectContaining({ tenantId: 't1', channel: 'mq' }),
);
});
});

View File

@@ -0,0 +1,15 @@
/**
* Minimal Express app with gateway routes only (for HTTP integration tests).
*/
import express, { Express } from 'express';
import gatewayRoutes from '@/core/gateway/routes/gateway.routes';
import { errorHandler } from '@/integration/api-gateway/middleware/error.middleware';
export function createGatewayHttpTestApp(): Express {
const app = express();
app.use(express.json());
app.use('/api/v1/gateway', gatewayRoutes);
app.use(errorHandler);
return app;
}

View File

@@ -0,0 +1,15 @@
/**
* Express app with IRU marketplace routes only (public + admin mount) for HTTP tests.
*/
import express, { Express } from 'express';
import iruMarketplacePublicRoutes from '@/integration/api-gateway/routes/iru-marketplace-public.routes';
import { errorHandler } from '@/integration/api-gateway/middleware/error.middleware';
export function createIruMarketplaceHttpTestApp(): Express {
const app = express();
app.use(express.json());
app.use('/api/v1/iru/marketplace', iruMarketplacePublicRoutes);
app.use(errorHandler);
return app;
}

View File

@@ -4,6 +4,7 @@
import prisma from '@/shared/database/prisma';
import { logger } from '@/infrastructure/monitoring/logger';
import Decimal from 'decimal.js';
import { listGatewayRailAdapterIds } from '@/core/gateway/adapters/gateway-adapter-registry';
export interface GASMetrics {
atomicSettlementSuccessRate: number;
@@ -133,7 +134,13 @@ export class GASQPSService {
messageTypes.set(msg.messageType, count + 1);
});
// Legacy rails (placeholder - would need actual QPS integration)
// Legacy rails: core buckets + SolaceNet gateway adapter IDs (volumes still heuristic until QPS integration)
const solaceNetRails = listGatewayRailAdapterIds().map((adapterId) => ({
railType: adapterId,
enabled: true,
volume24h: 0,
errorRate: 0,
}));
const legacyRails = [
{
railType: 'SWIFT',
@@ -153,6 +160,7 @@ export class GASQPSService {
volume24h: 0,
errorRate: 0,
},
...solaceNetRails,
];
// Mapping profiles (placeholder)

View File

@@ -1 +1 @@
export * from './debank-portfolio.service.js';
export * from './debank-portfolio.service';

View File

@@ -1,7 +1,7 @@
export * from './bridge-capability-matrix.js';
export * from './tezos-dex-quote.service.js';
export * from './chain138-quote.service.js';
export * from './ccip-fee.service.js';
export * from './allowlist.config.js';
export * from './tezos-signer.types.js';
export * from './route-planner.service.js';
export * from './bridge-capability-matrix';
export * from './tezos-dex-quote.service';
export * from './chain138-quote.service';
export * from './ccip-fee.service';
export * from './allowlist.config';
export * from './tezos-signer.types';
export * from './route-planner.service';

View File

@@ -2,12 +2,18 @@
* Route Planner - Chain138 to Tezos USDtz
*/
import { validateTezosAddress } from '../../../shared/utils/tezos-address.js';
import { getCandidateBridgesForPlanning, CHAIN_138, CHAIN_ETHEREUM, CHAIN_TEZOS, CHAIN_ALL_MAINNET } from './bridge-capability-matrix.js';
import { getChain138SwapQuote } from './chain138-quote.service.js';
import { getCCIPFeeEstimate } from './ccip-fee.service.js';
import { getTezosDexQuote } from './tezos-dex-quote.service.js';
import { validateRouteAllowlist } from './allowlist.config.js';
import { validateTezosAddress } from '../../../shared/utils/tezos-address';
import {
getCandidateBridgesForPlanning,
CHAIN_138,
CHAIN_ETHEREUM,
CHAIN_TEZOS,
CHAIN_ALL_MAINNET,
} from './bridge-capability-matrix';
import { getChain138SwapQuote } from './chain138-quote.service';
import { getCCIPFeeEstimate } from './ccip-fee.service';
import { getTezosDexQuote } from './tezos-dex-quote.service';
import { validateRouteAllowlist } from './allowlist.config';
export type ChainLabel = 'CHAIN138' | 'ALL_MAINNET' | 'HUB_EVM' | 'TEZOS';
export type HopAction = 'SWAP' | 'BRIDGE' | 'MINT' | 'REDEEM' | 'TRANSFER';

View File

@@ -179,18 +179,20 @@ export class GsdsContractService {
* Auto-close contract
*/
async autoClose(derivativeId: string, reason: Record<string, unknown>): Promise<void> {
const derivative = await prisma.synthetic_derivatives.findUnique({
where: { derivativeId },
});
const existing = derivative?.contractTerms;
const base =
typeof existing === 'object' && existing !== null
? (existing as Record<string, unknown>)
: {};
await prisma.synthetic_derivatives.update({
where: { derivativeId },
data: {
status: 'auto_closed',
contractTerms: (() => {
const derivative = await prisma.synthetic_derivatives.findUnique({
where: { derivativeId },
});
const existing = derivative?.contractTerms;
const base = typeof existing === 'object' && existing !== null ? existing as Record<string, unknown> : {};
return { ...base, autoCloseReason: reason } as Prisma.InputJsonValue;
})(),
contractTerms: { ...base, autoCloseReason: reason } as Prisma.InputJsonValue,
},
});
}
@@ -240,4 +242,3 @@ export class GsdsContractService {
}
export const gsdsContractService = new GsdsContractService();

View File

@@ -0,0 +1,13 @@
# Gateway rail adapters
TypeScript implementations of **external rail and protocol** surfaces (SWIFT, DTC/DTCC, TT, KTT evidence, etc.).
**Governance:** These protocols are **maintained under SolaceNet**—capability registry, policy, audit, and the Go edge gateway. See:
- `dbis_core/docs/solacenet/RAIL_AND_PROTOCOL_GOVERNANCE.md`
- `dbis_core/docs/solacenet/PROTOCOL_GAPS_CHECKLIST.md` (tracked gap IDs and close criteria)
- `dbis_core/SOLACENET_QUICK_REFERENCE.md`
New adapters should be registered with SolaceNet capabilities and documented with their trust model (full rail vs evidence-only vs northbound-only from a telecom boundary).
**Registry:** `gateway-adapter-registry.ts` lists canonical adapter IDs and factory functions (`createGatewayRailAdapter`, `listGatewayRailAdapterIds`). Add every new rail adapter there.

View File

@@ -0,0 +1,55 @@
import type { GatewayAdapter } from './sdk/adapter-interface';
import { DtcSettlementAdapter } from './dtc-settlement/dtc-settlement-adapter';
import { DtccFiccAdapter } from './dtcc/dtcc-ficc-adapter';
import { DtccNsccAdapter } from './dtcc/dtcc-nscc-adapter';
import { KttEvidenceAdapter } from './ktt-evidence/ktt-evidence-adapter';
import { SwiftFinAdapter } from './swift-fin/swift-fin-adapter';
import { SwiftGpiAdapter } from './swift-gpi/swift-gpi-adapter';
import { SwiftIsoAdapter } from './swift-iso/swift-iso-adapter';
import { ThirdwebAdapter } from './thirdweb/thirdweb-adapter';
import { TtRouteAdapter } from './tt-route/tt-route-adapter';
/**
* Canonical IDs for SolaceNet-maintained gateway rail adapters (see docs/solacenet/RAIL_AND_PROTOCOL_GOVERNANCE.md).
* Implementations are scaffolds until replaced with production connectors.
*/
export const GATEWAY_RAIL_ADAPTER_IDS = [
'dbis.adapter.swift-fin',
'dbis.adapter.swift-iso',
'dbis.adapter.swift-gpi',
'dbis.adapter.dtc-settlement',
'dbis.adapter.dtcc-nscc',
'dbis.adapter.dtcc-ficc',
'dbis.adapter.tt-route',
'dbis.adapter.ktt-evidence',
'dbis.adapter.thirdweb',
] as const;
export type GatewayRailAdapterId = (typeof GATEWAY_RAIL_ADAPTER_IDS)[number];
const factories: Record<GatewayRailAdapterId, () => GatewayAdapter> = {
'dbis.adapter.swift-fin': () => new SwiftFinAdapter(),
'dbis.adapter.swift-iso': () => new SwiftIsoAdapter(),
'dbis.adapter.swift-gpi': () => new SwiftGpiAdapter(),
'dbis.adapter.dtc-settlement': () => new DtcSettlementAdapter(),
'dbis.adapter.dtcc-nscc': () => new DtccNsccAdapter(),
'dbis.adapter.dtcc-ficc': () => new DtccFiccAdapter(),
'dbis.adapter.tt-route': () => new TtRouteAdapter(),
'dbis.adapter.ktt-evidence': () => new KttEvidenceAdapter(),
'dbis.adapter.thirdweb': () => new ThirdwebAdapter(),
};
/** All registered rail adapter IDs (stable order). */
export function listGatewayRailAdapterIds(): GatewayRailAdapterId[] {
return [...GATEWAY_RAIL_ADAPTER_IDS];
}
/** Instantiate a fresh adapter instance for the given ID (caller may `initialize()`). */
export function createGatewayRailAdapter(adapterId: string): GatewayAdapter | undefined {
if (!isGatewayRailAdapterId(adapterId)) return undefined;
return factories[adapterId]();
}
export function isGatewayRailAdapterId(id: string): id is GatewayRailAdapterId {
return (GATEWAY_RAIL_ADAPTER_IDS as readonly string[]).includes(id);
}

View File

@@ -4,6 +4,7 @@ import { AdapterReceiveResult, AdapterSendResult, AdapterValidateResult } from '
/**
* dbis.adapter.ktt-evidence
* Scaffold adapter to ingest legacy instruction artifacts as untrusted evidence.
* Rail/protocol governance: maintained under SolaceNet — see docs/solacenet/RAIL_AND_PROTOCOL_GOVERNANCE.md
*/
export class KttEvidenceAdapter extends AdapterBase {
async validate(_: Record<string, unknown>): Promise<AdapterValidateResult> {

View File

@@ -1,10 +1,11 @@
import type { Provider, Signer } from 'ethers';
import { AdapterBase } from '../sdk/adapter-base';
import { AdapterReceiveResult, AdapterSendResult, AdapterValidateResult } from '../sdk/adapter-types';
/**
* Thirdweb Gateway Adapter
*
* Adapter for interacting with smart contracts via Thirdweb SDK.
* dbis.adapter.thirdweb
*
* Thirdweb Gateway Adapter — contract invocation via Thirdweb SDK (not a bank messaging rail).
* Supports multiple chains and contract method invocation.
*/
export class ThirdwebAdapter extends AdapterBase {
@@ -400,13 +401,13 @@ export class ThirdwebAdapter extends AdapterBase {
// Create contract instance
// In production, ABI would be provided via envelope or configuration
// For now, we'll use a minimal approach with ethers.js
const provider = contractObj.provider as ethers.Provider;
const provider = contractObj.provider as Provider;
if (!contractObj.signer) {
throw new Error('Signer not available for transaction execution');
}
const signer = contractObj.signer as ethers.Signer;
const signer = contractObj.signer as Signer;
// Build transaction
const txRequest: Record<string, unknown> = {

View File

@@ -0,0 +1,17 @@
# Gateway rails enforcement
Optional SolaceNet capability checks for **gateway** HTTP routes.
| Variable | Effect |
|----------|--------|
| `SOLACENET_GATEWAY_RAILS_ENFORCE` | When `1` or `true`, requires capability **`gateway-microservices`** for tenant-sensitive gateway operations (rail health/validate/receive, instructions, event replay). |
| `SOLACENET_DEFAULT_TENANT_ID` | Fallback tenant when `x-tenant-id` header and `body.tenantId` are absent. |
| `SOLACENET_GATEWAY_AUDIT_LOG_PATH` | Optional path for **NDJSON** lines (one JSON object per allow/deny) in addition to structured Winston logs. |
| `GATEWAY_RAIL_MUTATE_WINDOW_MS` / `GATEWAY_RAIL_MUTATE_MAX` | Extra **per-IP** limit for `POST .../rails/:adapterId/validate|receive` (default 120 per 60s). |
| `GATEWAY_RAIL_RATE_LIMIT_IN_TEST` | Set to `1` in Jest so integration tests skip the rail mutate limiter (see `jest.gateway-http.config.js`). |
Tenant resolution order: **`body.tenantId`** → **`x-tenant-id`** → **`SOLACENET_DEFAULT_TENANT_ID`** → **`system`**.
**Worker ingress:** `requireGatewayMicroservicesForWorker()` in `gateway-rails-enforcement.ts` — call from file/MQ consumers when enforcement is on.
**Related:** `gateway-rails-audit.ts`, `../routes/gateway.routes.ts`, `../adapters/gateway-adapter-registry.ts`, `docs/solacenet/RAIL_AND_PROTOCOL_GOVERNANCE.md`, `docs/solacenet/SOLACENET_GATEWAY_RAILS_ENFORCE_RUNBOOK.md`.

View File

@@ -0,0 +1,42 @@
import fs from 'fs';
import { logger } from '@/infrastructure/monitoring/logger';
import type { ErrorCode } from '@/shared/types';
export type GatewayRailsAuditDecision = 'allow' | 'deny';
export interface GatewayRailsAuditRecord {
decision: GatewayRailsAuditDecision;
tenantId: string;
path: string;
method: string;
adapterId?: string;
errorCode?: ErrorCode | string;
correlationId?: string;
ingress?: string;
}
/**
* Structured audit for SolaceNet gateway rail capability checks (PG-GW-W03).
* Always logs to Winston; optionally appends one NDJSON line per event when
* SOLACENET_GATEWAY_AUDIT_LOG_PATH is set (operator-owned directory, rotation).
*/
export function recordGatewayRailsAudit(record: GatewayRailsAuditRecord): void {
const payload = {
auditType: 'gateway_rails_enforcement',
ts: new Date().toISOString(),
...record,
};
logger.info('Gateway rails enforcement audit', payload);
const path = process.env.SOLACENET_GATEWAY_AUDIT_LOG_PATH?.trim();
if (!path) return;
try {
fs.appendFileSync(path, `${JSON.stringify(payload)}\n`, { encoding: 'utf8', flag: 'a' });
} catch (err) {
logger.warn('SOLACENET_GATEWAY_AUDIT_LOG_PATH append failed', {
path,
error: err instanceof Error ? err.message : String(err),
});
}
}

View File

@@ -0,0 +1,124 @@
import type { Request } from 'express';
import { requireCapability } from '@/shared/solacenet/sdk';
import { DbisError } from '@/shared/types';
import { recordGatewayRailsAudit } from './gateway-rails-audit';
/**
* When SOLACENET_GATEWAY_RAILS_ENFORCE=1|true, rail mutating endpoints require
* SolaceNet capability `gateway-microservices` for the resolved tenant.
* Default is off so existing integrations keep working until explicitly enabled.
*/
export function gatewayRailsEnforcementEnabled(): boolean {
const v = process.env.SOLACENET_GATEWAY_RAILS_ENFORCE;
return v === '1' || v === 'true';
}
export function resolveGatewayTenantId(req: Request): string {
const h = req.headers['x-tenant-id'];
const fromHeader = typeof h === 'string' ? h : Array.isArray(h) ? h[0] : '';
const body = req.body && typeof req.body === 'object' ? (req.body as Record<string, unknown>) : {};
const fromBody = typeof body.tenantId === 'string' ? body.tenantId : '';
return (
fromBody ||
fromHeader ||
process.env.SOLACENET_DEFAULT_TENANT_ID ||
'system'
);
}
function gatewayRailsAdapterIdFromRequest(req: Request): string | undefined {
const id = req.params?.adapterId;
return typeof id === 'string' ? id : undefined;
}
function correlationIdFromRequest(req: Request): string | undefined {
const c = (req as { correlationId?: string }).correlationId;
return typeof c === 'string' ? c : undefined;
}
/**
* HTTP routes: enforce `gateway-microservices` when SOLACENET_GATEWAY_RAILS_ENFORCE is on.
* Emits allow/deny audit (log + optional NDJSON file).
*/
export async function maybeRequireGatewayMicroservices(req: Request): Promise<void> {
if (!gatewayRailsEnforcementEnabled()) return;
const tenantId = resolveGatewayTenantId(req);
const adapterId = gatewayRailsAdapterIdFromRequest(req);
const correlationId = correlationIdFromRequest(req);
try {
await requireCapability('gateway-microservices', {
tenantId,
capabilityId: 'gateway-microservices',
channel: 'API',
});
recordGatewayRailsAudit({
decision: 'allow',
tenantId,
path: req.path,
method: req.method,
adapterId,
correlationId,
ingress: 'http',
});
} catch (err) {
if (err instanceof DbisError) {
recordGatewayRailsAudit({
decision: 'deny',
tenantId,
path: req.path,
method: req.method,
adapterId,
correlationId,
errorCode: err.code,
ingress: 'http',
});
}
throw err;
}
}
export interface GatewayWorkerIngressContext {
tenantId: string;
/** e.g. file-drop, mq, worker */
ingressKind: string;
/** Free-form locator (queue name, file glob, job id) */
detail?: string;
adapterId?: string;
}
/**
* Non-HTTP ingress (file workers, MQ consumers): call at start of processing when
* SOLACENET_GATEWAY_RAILS_ENFORCE is enabled. Audit only; does not replace auth on the worker.
*/
export async function requireGatewayMicroservicesForWorker(ctx: GatewayWorkerIngressContext): Promise<void> {
if (!gatewayRailsEnforcementEnabled()) return;
try {
await requireCapability('gateway-microservices', {
tenantId: ctx.tenantId,
capabilityId: 'gateway-microservices',
channel: ctx.ingressKind,
});
recordGatewayRailsAudit({
decision: 'allow',
tenantId: ctx.tenantId,
path: ctx.detail ?? ctx.ingressKind,
method: 'WORKER',
adapterId: ctx.adapterId,
ingress: ctx.ingressKind,
});
} catch (err) {
if (err instanceof DbisError) {
recordGatewayRailsAudit({
decision: 'deny',
tenantId: ctx.tenantId,
path: ctx.detail ?? ctx.ingressKind,
method: 'WORKER',
adapterId: ctx.adapterId,
errorCode: err.code,
ingress: ctx.ingressKind,
});
}
throw err;
}
}

View File

@@ -1,20 +1,266 @@
/**
* @swagger
* tags:
* name: SolaceNet Gateway Rails
* description: SolaceNet-maintained financial rail adapters (metadata, health, validate, receive)
*/
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import { v4 as uuidv4 } from 'uuid';
import { gatewayApiService } from '../edge/api-gateway.service';
import { eventStoreService } from '../data/event-store.service';
import { gatewayInboxService } from '../control/inbox.service';
import {
createGatewayRailAdapter,
isGatewayRailAdapterId,
listGatewayRailAdapterIds,
} from '../adapters/gateway-adapter-registry';
import {
gatewayRailsEnforcementEnabled,
maybeRequireGatewayMicroservices,
} from '../rails/gateway-rails-enforcement';
import { DbisError } from '@/shared/types';
const router = Router();
// Attach correlation middleware for all gateway routes
router.use(gatewayApiService.correlationMiddleware.bind(gatewayApiService));
// Stricter budget for POST validate/receive (per-IP; stacks with global /api limiter).
if (process.env.GATEWAY_RAIL_RATE_LIMIT_IN_TEST !== '1') {
const windowMs = parseInt(process.env.GATEWAY_RAIL_MUTATE_WINDOW_MS || '60000', 10);
const max = parseInt(process.env.GATEWAY_RAIL_MUTATE_MAX || '120', 10);
const railMutateLimiter = rateLimit({
windowMs,
max,
standardHeaders: true,
legacyHeaders: false,
message: {
success: false,
error: {
code: 'RATE_LIMIT_EXCEEDED',
message: `Rail validate/receive limit: ${max} requests per ${Math.ceil(windowMs / 1000)}s.`,
},
timestamp: new Date(),
},
});
router.use((req, res, next) => {
if (req.method === 'POST' && /^\/rails\/[^/]+\/(validate|receive)\/?$/.test(req.path)) {
return railMutateLimiter(req, res, next);
}
next();
});
}
/**
* POST /api/v1/gateway/instructions
* Scaffold endpoint to accept a canonical instruction and emit an event.
* @swagger
* /api/v1/gateway/rails:
* get:
* summary: List rail adapter IDs
* description: SolaceNet-maintained gateway rail adapter identifiers (no live wire I/O). Uses the same API auth as other /api/v1 routes; does not call SolaceNet rail enforcement (metadata only).
* tags: [SolaceNet Gateway Rails]
* security:
* - SovereignToken: []
* responses:
* 200:
* description: Adapter list and enforcement flag
*/
router.get('/rails', async (_req, res, next) => {
try {
return res.json({
maintainer: 'SolaceNet',
enforcementEnabled: gatewayRailsEnforcementEnabled(),
adapters: listGatewayRailAdapterIds(),
governanceDoc: 'dbis_core/docs/solacenet/RAIL_AND_PROTOCOL_GOVERNANCE.md',
});
} catch (error) {
return next(error);
}
});
/**
* @swagger
* /api/v1/gateway/rails/{adapterId}/health:
* get:
* summary: Rail adapter health
* tags: [SolaceNet Gateway Rails]
* security:
* - SovereignToken: []
* parameters:
* - in: path
* name: adapterId
* required: true
* schema:
* type: string
* description: e.g. dbis.adapter.swift-fin (URL-encode dots if your client requires it)
* responses:
* 200:
* description: Health status
* 404:
* description: Unknown adapter
* 403:
* description: SolaceNet capability denied when SOLACENET_GATEWAY_RAILS_ENFORCE is enabled
*/
router.get('/rails/:adapterId/health', async (req, res, next) => {
try {
await maybeRequireGatewayMicroservices(req);
const adapterId = req.params.adapterId;
if (!isGatewayRailAdapterId(adapterId)) {
return res.status(404).json({ error: 'UNKNOWN_ADAPTER', adapterId });
}
const adapter = createGatewayRailAdapter(adapterId)!;
await adapter.initialize({}, undefined);
const health = await adapter.health();
return res.json({ adapterId, ...health });
} catch (error) {
if (error instanceof DbisError) {
return res.status(403).json({ error: error.message, code: error.code });
}
return next(error);
}
});
/**
* @swagger
* /api/v1/gateway/rails/{adapterId}/validate:
* post:
* summary: Validate canonical instruction against a rail adapter
* tags: [SolaceNet Gateway Rails]
* security:
* - SovereignToken: []
* parameters:
* - in: path
* name: adapterId
* required: true
* schema:
* type: string
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* tenantId:
* type: string
* canonicalInstruction:
* type: object
* responses:
* 200:
* description: Validation result
*/
router.post('/rails/:adapterId/validate', async (req, res, next) => {
try {
await maybeRequireGatewayMicroservices(req);
const adapterId = req.params.adapterId;
if (!isGatewayRailAdapterId(adapterId)) {
return res.status(404).json({ error: 'UNKNOWN_ADAPTER', adapterId });
}
const body = req.body || {};
const canonicalInstruction =
body.canonicalInstruction && typeof body.canonicalInstruction === 'object'
? (body.canonicalInstruction as Record<string, unknown>)
: {};
const adapter = createGatewayRailAdapter(adapterId)!;
await adapter.initialize({}, undefined);
const result = await adapter.validate(canonicalInstruction);
return res.json({ adapterId, ...result });
} catch (error) {
if (error instanceof DbisError) {
return res.status(403).json({ error: error.message, code: error.code });
}
return next(error);
}
});
/**
* @swagger
* /api/v1/gateway/rails/{adapterId}/receive:
* post:
* summary: Receive / ingest rail payload (scaffold)
* tags: [SolaceNet Gateway Rails]
* security:
* - SovereignToken: []
* parameters:
* - in: path
* name: adapterId
* required: true
* schema:
* type: string
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* tenantId:
* type: string
* payload: {}
* responses:
* 200:
* description: Receive result
*/
router.post('/rails/:adapterId/receive', async (req, res, next) => {
try {
await maybeRequireGatewayMicroservices(req);
const adapterId = req.params.adapterId;
if (!isGatewayRailAdapterId(adapterId)) {
return res.status(404).json({ error: 'UNKNOWN_ADAPTER', adapterId });
}
const payload = (req.body && 'payload' in req.body ? req.body.payload : req.body) as unknown;
const adapter = createGatewayRailAdapter(adapterId)!;
await adapter.initialize({}, undefined);
const result = await adapter.receive(payload);
return res.json({ adapterId, ...result });
} catch (error) {
if (error instanceof DbisError) {
return res.status(403).json({ error: error.message, code: error.code });
}
return next(error);
}
});
/**
* @swagger
* /api/v1/gateway/instructions:
* post:
* summary: Ingest canonical instruction (scaffold)
* description: Accepts a JSON body, deduplicates via gateway inbox (scaffold), appends InstructionReceived to the in-memory event store, returns 202. When SOLACENET_GATEWAY_RAILS_ENFORCE is set, requires gateway-microservices for the resolved tenant (header x-tenant-id or body.tenantId).
* tags: [SolaceNet Gateway Rails]
* security:
* - SovereignToken: []
* requestBody:
* required: false
* content:
* application/json:
* schema:
* type: object
* properties:
* tenantId:
* type: string
* description: Tenant for capability check when enforcement is enabled
* txnId:
* type: string
* description: Optional idempotency / trace id (server generates if omitted)
* rail:
* type: string
* example: CANONICAL
* amount:
* type: string
* currency:
* type: string
* metadata:
* type: object
* additionalProperties: true
* responses:
* 202:
* description: Accepted — instruction received (scaffold)
* 403:
* description: SolaceNet capability denied when enforcement enabled
*/
router.post('/instructions', async (req, res, next) => {
try {
await maybeRequireGatewayMicroservices(req);
const txnId = req.body?.txnId || `TXN-${uuidv4()}`;
const correlationId = (req as any).correlationId || `CORR-${uuidv4()}`;
@@ -36,20 +282,44 @@ router.post('/instructions', async (req, res, next) => {
return res.status(202).json({ txnId, correlationId, status: 'RECEIVED' });
} catch (error) {
if (error instanceof DbisError) {
return res.status(403).json({ error: error.message, code: error.code });
}
return next(error);
}
});
/**
* GET /api/v1/gateway/events/replay
* Simple replay endpoint for canonical events (scaffold).
* @swagger
* /api/v1/gateway/events/replay:
* get:
* summary: Replay canonical events (scaffold, in-memory)
* tags: [SolaceNet Gateway Rails]
* security:
* - SovereignToken: []
* parameters:
* - in: query
* name: from
* schema:
* type: integer
* default: 0
* description: Start index into the in-memory event buffer
* responses:
* 200:
* description: Event slice
* 403:
* description: SolaceNet capability denied when enforcement enabled
*/
router.get('/events/replay', async (req, res, next) => {
try {
await maybeRequireGatewayMicroservices(req);
const from = Number(req.query.from || 0);
const events = eventStoreService.replay(from);
return res.json({ count: events.length, events });
} catch (error) {
if (error instanceof DbisError) {
return res.status(403).json({ error: error.message, code: error.code });
}
return next(error);
}
});

View File

@@ -1,6 +1,7 @@
// IRU Marketplace Service
// Business logic for Sankofa Phoenix Marketplace
import { randomBytes } from 'crypto';
import prisma from '@/shared/database/prisma';
import { v4 as uuidv4 } from 'uuid';
import { DbisError, ErrorCode } from '@/shared/types';
@@ -150,7 +151,7 @@ export class MarketplaceService {
const existingInquiry = await prisma.iruInquiry.findFirst({
where: {
contactEmail: request.contactEmail,
offeringId: request.offeringId,
offeringId: offering.id,
status: {
in: ['submitted', 'acknowledged', 'in_review'],
},
@@ -164,13 +165,13 @@ export class MarketplaceService {
);
}
// Create inquiry
const inquiryId = `INQ-${uuidv4().substring(0, 8).toUpperCase()}`;
// Unpredictable public id (INQ- + 32 hex) for unauthenticated status lookups
const inquiryId = `INQ-${randomBytes(16).toString('hex').toUpperCase()}`;
const inquiry = await prisma.iruInquiry.create({
data: {
id: uuidv4(),
inquiryId,
offeringId: request.offeringId,
offeringId: offering.id,
organizationName: request.organizationName,
institutionalType: request.institutionalType,
jurisdiction: request.jurisdiction,
@@ -194,7 +195,7 @@ export class MarketplaceService {
variables: {
inquiryId: inquiry.inquiryId,
organizationName: inquiry.organizationName,
offeringId: inquiry.offeringId,
offeringId: offering.offeringId,
},
priority: 'high',
});
@@ -236,7 +237,42 @@ export class MarketplaceService {
}
/**
* Get inquiry status
* Public inquiry status (unauthenticated). No org/contact, qualification, risk, or notes.
*/
async getInquiryStatusPublic(inquiryId: string): Promise<{
inquiryId: string;
status: string;
offering: { name: string; capacityTier: number };
submittedAt: Date;
acknowledgedAt: Date | null;
reviewedAt: Date | null;
completedAt: Date | null;
}> {
const inquiry = await prisma.iruInquiry.findUnique({
where: { inquiryId },
include: { offering: true },
});
if (!inquiry) {
throw new DbisError(ErrorCode.NOT_FOUND, `Inquiry ${inquiryId} not found`);
}
return {
inquiryId: inquiry.inquiryId,
status: inquiry.status,
offering: {
name: inquiry.offering.name,
capacityTier: inquiry.offering.capacityTier,
},
submittedAt: inquiry.submittedAt,
acknowledgedAt: inquiry.acknowledgedAt,
reviewedAt: inquiry.reviewedAt,
completedAt: inquiry.completedAt,
};
}
/**
* Full inquiry row (operators / legacy). Prefer getInquiryStatusPublic for public HTTP.
*/
async getInquiryStatus(inquiryId: string): Promise<any> {
const inquiry = await prisma.iruInquiry.findUnique({

View File

@@ -81,7 +81,7 @@ export class NotificationService {
],
from: {
email: process.env.EMAIL_FROM || 'noreply@dbis.org',
name: 'DBIS IRU',
name: process.env.EMAIL_FROM_NAME || 'SolaceNet',
},
content: [
{

View File

@@ -24,6 +24,7 @@ export class SMTPIntegration {
const smtpUser = process.env.SMTP_USER;
const smtpPassword = process.env.SMTP_PASSWORD;
const smtpSecure = process.env.SMTP_SECURE === 'true';
const tlsRejectUnauthorized = process.env.SMTP_TLS_REJECT_UNAUTHORIZED !== 'false';
const fromEmail = request.from || process.env.EMAIL_FROM || 'noreply@dbis.org';
// In production, use nodemailer:
@@ -63,6 +64,7 @@ export class SMTPIntegration {
pass: smtpPassword,
}
: undefined,
tls: tlsRejectUnauthorized ? undefined : { rejectUnauthorized: false },
});
result = await retryWithBackoff(

View File

@@ -78,57 +78,65 @@ export class TemplateLoaderService {
'inquiry-submitted': {
id: 'inquiry-submitted',
name: 'inquiry-submitted',
subject: 'IRU Inquiry Submitted',
body: 'Your IRU inquiry has been submitted successfully. Inquiry ID: {{inquiryId}}',
subject: 'SolaceNet (IRU) inquiry submitted — Solace Bank Group PLC',
body:
'Your SolaceNet (Irrevocable Right of Use) inquiry has been submitted. Vendor: Solace Bank Group PLC. Inquiry ID: {{inquiryId}}',
variables: ['inquiryId'],
},
'inquiry-acknowledged': {
id: 'inquiry-acknowledged',
name: 'inquiry-acknowledged',
subject: 'IRU Inquiry Acknowledged',
body: 'Your IRU inquiry has been acknowledged. We will review it within 24 hours. Inquiry ID: {{inquiryId}}',
subject: 'SolaceNet inquiry acknowledged — Solace Bank Group PLC',
body:
'Your SolaceNet (IRU) inquiry has been acknowledged. We will review it within 24 hours. Inquiry ID: {{inquiryId}}',
variables: ['inquiryId'],
},
'qualification-complete': {
id: 'qualification-complete',
name: 'qualification-complete',
subject: 'IRU Qualification Complete',
body: 'Your IRU qualification is complete. Status: {{status}}. Inquiry ID: {{inquiryId}}',
subject: 'SolaceNet qualification complete — Solace Bank Group PLC',
body:
'Your SolaceNet (IRU) qualification is complete. Status: {{status}}. Inquiry ID: {{inquiryId}}',
variables: ['status', 'inquiryId'],
},
'agreement-ready': {
id: 'agreement-ready',
name: 'agreement-ready',
subject: 'IRU Agreement Ready for Signature',
body: 'Your IRU Participation Agreement is ready for signature. Agreement ID: {{agreementId}}',
subject: 'SolaceNet agreement ready for signature',
body:
'Your SolaceNet participation agreement is ready for signature. Agreement ID: {{agreementId}}',
variables: ['agreementId'],
},
'deployment-complete': {
id: 'deployment-complete',
name: 'deployment-complete',
subject: 'IRU Deployment Complete',
body: 'Your IRU infrastructure has been deployed successfully. Subscription ID: {{subscriptionId}}',
subject: 'SolaceNet deployment complete',
body:
'Your SolaceNet (IRU) infrastructure has been deployed successfully. Subscription ID: {{subscriptionId}}',
variables: ['subscriptionId'],
},
'deployment-failed': {
id: 'deployment-failed',
name: 'deployment-failed',
subject: 'IRU Deployment Failed',
body: 'Your IRU deployment has failed. Deployment ID: {{deploymentId}}. Error: {{error}}',
subject: 'SolaceNet deployment failed',
body:
'Your SolaceNet (IRU) deployment has failed. Deployment ID: {{deploymentId}}. Error: {{error}}',
variables: ['deploymentId', 'error'],
},
'payment-success': {
id: 'payment-success',
name: 'payment-success',
subject: 'Payment Successful',
body: 'Your payment for IRU subscription {{subscriptionId}} has been processed successfully. Amount: {{currency}} {{amount}}',
subject: 'Payment successful — SolaceNet',
body:
'Your payment for SolaceNet subscription {{subscriptionId}} has been processed successfully. Amount: {{currency}} {{amount}}',
variables: ['subscriptionId', 'currency', 'amount'],
},
'payment-failed': {
id: 'payment-failed',
name: 'payment-failed',
subject: 'Payment Failed',
body: 'Your payment for IRU subscription {{subscriptionId}} has failed. Transaction ID: {{transactionId}}',
subject: 'Payment failed — SolaceNet',
body:
'Your payment for SolaceNet subscription {{subscriptionId}} has failed. Transaction ID: {{transactionId}}',
variables: ['subscriptionId', 'transactionId'],
},
};
@@ -137,8 +145,8 @@ export class TemplateLoaderService {
templates[templateName] || {
id: templateName,
name: templateName,
subject: 'DBIS IRU Notification',
body: 'You have a new notification from DBIS IRU.',
subject: 'SolaceNet / Sankofa Marketplace notification',
body: 'You have a new notification regarding SolaceNet (IRU) on Sankofa Marketplace.',
variables: [],
}
);

View File

@@ -43,6 +43,8 @@ export class CardIssuingService {
await requireCapability('card-issuing', {
tenantId: request.tenantId,
programId: request.programId,
capabilityId: 'card-issuing',
channel: 'API',
});
// Risk assessment
@@ -84,6 +86,8 @@ export class CardIssuingService {
async controlCard(request: CardControlRequest): Promise<void> {
await requireCapability('card-controls', {
tenantId: request.tenantId,
capabilityId: 'card-controls',
channel: 'API',
});
logger.info(`Card control action`, {

View File

@@ -33,6 +33,8 @@ export class MobileMoneyService {
tenantId: request.tenantId,
programId: request.programId,
region: request.region,
capabilityId: 'mobile-money-connector',
channel: 'API',
});
// Route to specific capability based on transaction type
@@ -40,16 +42,22 @@ export class MobileMoneyService {
await requireCapability('mobile-money-cash-in', {
tenantId: request.tenantId,
region: request.region,
capabilityId: 'mobile-money-cash-in',
channel: 'API',
});
} else if (request.transactionType === 'cash-out') {
await requireCapability('mobile-money-cash-out', {
tenantId: request.tenantId,
region: request.region,
capabilityId: 'mobile-money-cash-out',
channel: 'API',
});
} else if (request.transactionType === 'transfer') {
await requireCapability('mobile-money-transfers', {
tenantId: request.tenantId,
region: request.region,
capabilityId: 'mobile-money-transfers',
channel: 'API',
});
}

View File

@@ -43,6 +43,7 @@ export class PaymentGatewayService {
programId: request.programId,
region: request.region,
channel: request.channel,
capabilityId: 'payment-gateway',
});
// Check limits
@@ -76,7 +77,11 @@ export class PaymentGatewayService {
* Capture a payment intent
*/
async capturePayment(intentId: string, tenantId: string): Promise<void> {
await requireCapability('payment-gateway', { tenantId });
await requireCapability('payment-gateway', {
tenantId,
capabilityId: 'payment-gateway',
channel: 'API',
});
// In production, fetch intent, calculate fees, post to ledger
logger.info(`Payment captured`, { intentId });
@@ -86,7 +91,11 @@ export class PaymentGatewayService {
* Refund a payment
*/
async refundPayment(paymentId: string, amount: string, tenantId: string): Promise<void> {
await requireCapability('payment-gateway', { tenantId });
await requireCapability('payment-gateway', {
tenantId,
capabilityId: 'payment-gateway',
channel: 'API',
});
// In production, process refund, post to ledger
logger.info(`Payment refunded`, { paymentId, amount });

View File

@@ -56,7 +56,8 @@ router.post('/mint', async (req, res) => {
tenantId: req.headers['x-tenant-id'] as string || 'default',
programId: req.headers['x-program-id'] as string || 'default',
region: req.headers['x-region'] as string || 'default',
channel: req.headers['x-channel'] as string || 'default'
channel: req.headers['x-channel'] as string || 'default',
capabilityId: 'tokenization.mint',
};
const result = await tokenizationService.mintToken(req.body, context);
@@ -84,7 +85,8 @@ router.post('/transfer', async (req, res) => {
tenantId: req.headers['x-tenant-id'] as string || 'default',
programId: req.headers['x-program-id'] as string || 'default',
region: req.headers['x-region'] as string || 'default',
channel: req.headers['x-channel'] as string || 'default'
channel: req.headers['x-channel'] as string || 'default',
capabilityId: 'tokenization.transfer',
};
const { tokenId, from, to, amount } = req.body;
@@ -113,7 +115,8 @@ router.post('/redeem', async (req, res) => {
tenantId: req.headers['x-tenant-id'] as string || 'default',
programId: req.headers['x-program-id'] as string || 'default',
region: req.headers['x-region'] as string || 'default',
channel: req.headers['x-channel'] as string || 'default'
channel: req.headers['x-channel'] as string || 'default',
capabilityId: 'tokenization.redeem',
};
const { tokenId, redeemer, amount } = req.body;
@@ -142,7 +145,8 @@ router.get('/status/:requestId', async (req, res) => {
tenantId: req.headers['x-tenant-id'] as string || 'default',
programId: req.headers['x-program-id'] as string || 'default',
region: req.headers['x-region'] as string || 'default',
channel: req.headers['x-channel'] as string || 'default'
channel: req.headers['x-channel'] as string || 'default',
capabilityId: 'tokenization.view',
};
const result = await tokenizationService.getStatus(req.params.requestId, context);
@@ -170,7 +174,8 @@ router.get('/token/:tokenId', async (req, res) => {
tenantId: req.headers['x-tenant-id'] as string || 'default',
programId: req.headers['x-program-id'] as string || 'default',
region: req.headers['x-region'] as string || 'default',
channel: req.headers['x-channel'] as string || 'default'
channel: req.headers['x-channel'] as string || 'default',
capabilityId: 'tokenization.view',
};
const result = await tokenizationService.getTokenDetails(req.params.tokenId, context);

View File

@@ -44,6 +44,8 @@ export class WalletAccountsService {
await requireCapability('wallet-accounts', {
tenantId: request.tenantId,
programId: request.programId,
capabilityId: 'wallet-accounts',
channel: 'API',
});
const wallet: WalletAccount = {
@@ -66,7 +68,11 @@ export class WalletAccountsService {
* Get wallet by ID
*/
async getWallet(walletId: string, tenantId: string): Promise<WalletAccount | null> {
await requireCapability('wallet-accounts', { tenantId });
await requireCapability('wallet-accounts', {
tenantId,
capabilityId: 'wallet-accounts',
channel: 'API',
});
// In production, fetch from database
return null;
}
@@ -77,6 +83,8 @@ export class WalletAccountsService {
async transfer(request: WalletTransferRequest): Promise<void> {
await requireCapability('p2p-transfers', {
tenantId: request.tenantId,
capabilityId: 'p2p-transfers',
channel: 'API',
});
// Post to ledger

View File

@@ -2,6 +2,7 @@
// Manages tenant/program/region/channel entitlements with allowlist support
import { v4 as uuidv4 } from 'uuid';
import type { Prisma } from '@prisma/client';
import prisma from '@/shared/database/prisma';
import { DbisError, ErrorCode } from '@/shared/types';
import { logger } from '@/infrastructure/monitoring/logger';
@@ -85,13 +86,14 @@ export class EntitlementsService {
// Check if entitlement already exists
const existing = await prisma.solacenet_entitlement.findUnique({
where: {
// Generated client types require string for nullable composite dims; DB/query accept null.
tenantId_programId_capabilityId_region_channel: {
tenantId: request.tenantId,
programId: request.programId || null,
programId: request.programId ?? null,
capabilityId: request.capabilityId,
region: request.region || null,
channel: request.channel || null,
},
region: request.region ?? null,
channel: request.channel ?? null,
} as any,
},
});
@@ -106,15 +108,16 @@ export class EntitlementsService {
data: {
id: uuidv4(),
tenantId: request.tenantId,
programId: request.programId || null,
programId: request.programId ?? undefined,
capabilityId: request.capabilityId,
region: request.region || null,
channel: request.channel || null,
stateOverride: request.stateOverride || null,
allowlist: request.allowlist || [],
region: request.region ?? undefined,
channel: request.channel ?? undefined,
stateOverride: request.stateOverride ?? undefined,
allowlist: (request.allowlist ?? []) as Prisma.InputJsonValue,
effectiveFrom: request.effectiveFrom || new Date(),
effectiveTo: request.effectiveTo || null,
metadata: request.metadata || null,
effectiveTo: request.effectiveTo ?? undefined,
metadata:
request.metadata != null ? (request.metadata as Prisma.InputJsonValue) : undefined,
},
include: {
capability: true,
@@ -140,14 +143,18 @@ export class EntitlementsService {
throw new DbisError(ErrorCode.NOT_FOUND, `Entitlement ${id} not found`);
}
const nextAllowlist = updates.allowlist ?? entitlement.allowlist;
const nextMetadata = updates.metadata ?? entitlement.metadata;
const updated = await prisma.solacenet_entitlement.update({
where: { id },
data: {
stateOverride: updates.stateOverride || entitlement.stateOverride,
allowlist: updates.allowlist || entitlement.allowlist,
stateOverride: updates.stateOverride ?? entitlement.stateOverride ?? undefined,
allowlist: nextAllowlist as Prisma.InputJsonValue,
effectiveFrom: updates.effectiveFrom || entitlement.effectiveFrom,
effectiveTo: updates.effectiveTo !== undefined ? updates.effectiveTo : entitlement.effectiveTo,
metadata: updates.metadata || entitlement.metadata,
metadata:
nextMetadata != null ? (nextMetadata as Prisma.InputJsonValue) : undefined,
},
include: {
capability: true,

View File

@@ -2,6 +2,7 @@
// Makes runtime policy decisions based on entitlements, rules, and conditions
import { v4 as uuidv4 } from 'uuid';
import type { Prisma } from '@prisma/client';
import prisma from '@/shared/database/prisma';
import { DbisError, ErrorCode } from '@/shared/types';
import { logger } from '@/infrastructure/monitoring/logger';
@@ -239,14 +240,16 @@ export class PolicyEngineService {
capabilityId: request.capabilityId,
scope: request.scope,
scopeValue: request.scopeValue || null,
condition: request.condition,
condition: request.condition as Prisma.InputJsonValue,
decision: request.decision,
limits: request.limits || null,
limits:
request.limits != null ? (request.limits as Prisma.InputJsonValue) : undefined,
reason: request.reason || null,
ticket: request.ticket || null,
priority: request.priority || 100,
status: 'active',
metadata: request.metadata || null,
metadata:
request.metadata != null ? (request.metadata as Prisma.InputJsonValue) : undefined,
},
});

View File

@@ -2,6 +2,7 @@
// Manages the registry of all capabilities with CRUD operations, dependency validation, and version management
import { v4 as uuidv4 } from 'uuid';
import type { Prisma } from '@prisma/client';
import prisma from '@/shared/database/prisma';
import { DbisError, ErrorCode } from '@/shared/types';
import { logger } from '@/infrastructure/monitoring/logger';
@@ -106,11 +107,15 @@ export class CapabilityRegistryService {
version: request.version || '1.0.0',
description: request.description,
ownerTeam: request.ownerTeam,
dependencies: request.dependencies || [],
configSchema: request.configSchema || null,
dependencies: (request.dependencies || []) as Prisma.InputJsonValue,
configSchema:
request.configSchema != null
? (request.configSchema as Prisma.InputJsonValue)
: undefined,
defaultState: request.defaultState || CapabilityState.DISABLED,
status: 'active',
metadata: request.metadata || null,
metadata:
request.metadata != null ? (request.metadata as Prisma.InputJsonValue) : undefined,
},
});
@@ -180,19 +185,30 @@ export class CapabilityRegistryService {
);
}
const updateData: Prisma.solacenet_capabilityUpdateInput = {};
if (request.name !== undefined) updateData.name = request.name;
if (request.version !== undefined) updateData.version = request.version;
if (request.description !== undefined) updateData.description = request.description;
if (request.ownerTeam !== undefined) updateData.ownerTeam = request.ownerTeam;
if (request.dependencies !== undefined) {
updateData.dependencies = request.dependencies as Prisma.InputJsonValue;
}
if (request.configSchema !== undefined) {
updateData.configSchema =
request.configSchema != null
? (request.configSchema as Prisma.InputJsonValue)
: undefined;
}
if (request.defaultState !== undefined) updateData.defaultState = request.defaultState;
if (request.status !== undefined) updateData.status = request.status;
if (request.metadata !== undefined) {
updateData.metadata =
request.metadata != null ? (request.metadata as Prisma.InputJsonValue) : undefined;
}
const capability = await prisma.solacenet_capability.update({
where: { capabilityId },
data: {
name: request.name,
version: request.version,
description: request.description,
ownerTeam: request.ownerTeam,
dependencies: request.dependencies,
configSchema: request.configSchema,
defaultState: request.defaultState,
status: request.status,
metadata: request.metadata,
},
data: updateData,
});
logger.info(`Capability updated: ${capabilityId}`);
@@ -329,7 +345,7 @@ export class CapabilityRegistryService {
return bindings.map((binding) => ({
id: binding.id,
capabilityId: binding.capabilityId,
providerId: binding.providerId,
providerId: binding.providerId ?? undefined,
region: binding.region,
config: binding.config as Record<string, unknown> | undefined,
secretsRef: binding.secretsRef,
@@ -391,7 +407,7 @@ export class CapabilityRegistryService {
capabilityId,
providerId: providerId || null,
region,
config: config || null,
config: config != null ? (config as Prisma.InputJsonValue) : undefined,
status: 'active',
},
});

View File

@@ -0,0 +1,46 @@
/**
* Legacy / rail protocols visible to the quantum translation & compatibility layer.
* Aligns with SolaceNet gateway rails where applicable (see gateway-adapter-registry).
*/
export type LegacyProtocol =
| 'SWIFT'
| 'ISO20022'
| 'ACH'
| 'SEPA'
| 'PRIVATE_BANK'
| 'KTT_EVIDENCE'
| 'TT_ROUTE'
| 'DTC_SETTLEMENT'
| 'DTCC_NSCC'
| 'DTCC_FICC'
| 'SWIFT_GPI'
| 'MOJALOOP'
| 'RTGS'
| 'CARD_NETWORK';
/** Subset used for SolaceNet-aligned scaffolds (non-SWIFT/ACH core banking). */
export const SOLACENET_EXTENDED_PROTOCOLS: readonly LegacyProtocol[] = [
'KTT_EVIDENCE',
'TT_ROUTE',
'DTC_SETTLEMENT',
'DTCC_NSCC',
'DTCC_FICC',
'SWIFT_GPI',
'MOJALOOP',
'RTGS',
'CARD_NETWORK',
] as const;
export function isSolaceNetExtendedProtocol(p: string): p is LegacyProtocol {
return (SOLACENET_EXTENDED_PROTOCOLS as readonly string[]).includes(p);
}
/** All values accepted by quantum translation / compatibility services. */
export const ALL_LEGACY_PROTOCOLS: readonly LegacyProtocol[] = [
'SWIFT',
'ISO20022',
'ACH',
'SEPA',
'PRIVATE_BANK',
...SOLACENET_EXTENDED_PROTOCOLS,
] as const;

View File

@@ -4,10 +4,10 @@
import prisma from '@/shared/database/prisma';
import { v4 as uuidv4 } from 'uuid';
import { logger } from '@/infrastructure/monitoring/logger';
import { ALL_LEGACY_PROTOCOLS, type LegacyProtocol } from './legacy-protocol-types';
export interface CompatibilityCheckRequest {
legacyProtocol: 'SWIFT' | 'ISO20022' | 'ACH' | 'SEPA' | 'PRIVATE_BANK';
legacyProtocol: LegacyProtocol;
transactionData: any;
}
@@ -75,6 +75,22 @@ export class QuantumCompatibilityService {
);
break;
case 'KTT_EVIDENCE':
case 'TT_ROUTE':
case 'DTC_SETTLEMENT':
case 'DTCC_NSCC':
case 'DTCC_FICC':
case 'SWIFT_GPI':
case 'MOJALOOP':
case 'RTGS':
case 'CARD_NETWORK':
compatibilityScore = this.checkSolaceNetRailScaffoldCompatibility(
request.legacyProtocol,
issues,
recommendations
);
break;
default:
issues.push(`Unknown protocol: ${request.legacyProtocol}`);
compatibilityScore = 0;
@@ -90,6 +106,21 @@ export class QuantumCompatibilityService {
};
}
/**
* SolaceNet-aligned rail scaffolds: neutral score until real field contracts exist.
*/
private checkSolaceNetRailScaffoldCompatibility(
protocol: LegacyProtocol,
issues: string[],
recommendations: string[]
): number {
issues.push(`Scaffold protocol mapping for ${protocol}; validate against live rail contract before production.`);
recommendations.push(
'See dbis_core/docs/solacenet/PROTOCOL_GAPS_CHECKLIST.md and gateway-adapter-registry for adapter status.'
);
return 72;
}
/**
* Check SWIFT compatibility
*/
@@ -255,7 +286,7 @@ export class QuantumCompatibilityService {
* List all supported protocols
*/
async listSupportedProtocols(): Promise<string[]> {
return ['SWIFT', 'ISO20022', 'ACH', 'SEPA', 'PRIVATE_BANK'];
return [...ALL_LEGACY_PROTOCOLS];
}
}

View File

@@ -5,10 +5,10 @@ import prisma from '@/shared/database/prisma';
import { Decimal } from '@prisma/client/runtime/library';
import { v4 as uuidv4 } from 'uuid';
import { logger } from '@/infrastructure/monitoring/logger';
import type { LegacyProtocol } from './legacy-protocol-types';
export interface QuantumTranslationRequest {
legacyProtocol: 'SWIFT' | 'ISO20022' | 'ACH' | 'SEPA' | 'PRIVATE_BANK';
legacyProtocol: LegacyProtocol;
transactionData: any;
amount: string;
currencyCode: string;
@@ -158,6 +158,15 @@ export class QuantumTranslationService {
currency: 'currencyCode',
},
},
KTT_EVIDENCE: { messageType: 'KTT', quantumFormat: 'ISO20022', fieldMapping: {} },
TT_ROUTE: { messageType: 'TT', quantumFormat: 'ISO20022', fieldMapping: {} },
DTC_SETTLEMENT: { messageType: 'DTC', quantumFormat: 'ISO20022', fieldMapping: {} },
DTCC_NSCC: { messageType: 'DTCC_NSCC', quantumFormat: 'ISO20022', fieldMapping: {} },
DTCC_FICC: { messageType: 'DTCC_FICC', quantumFormat: 'ISO20022', fieldMapping: {} },
SWIFT_GPI: { messageType: 'SWIFT_GPI', quantumFormat: 'ISO20022', fieldMapping: {} },
MOJALOOP: { messageType: 'MOJALOOP', quantumFormat: 'ISO20022', fieldMapping: {} },
RTGS: { messageType: 'RTGS', quantumFormat: 'ISO20022', fieldMapping: {} },
CARD_NETWORK: { messageType: 'CARD', quantumFormat: 'ISO20022', fieldMapping: {} },
};
return defaultMappings[legacyProtocol] || {};
@@ -205,6 +214,17 @@ export class QuantumTranslationService {
fxRate = new Decimal(0.85); // Example EUR rate
}
break;
case 'KTT_EVIDENCE':
case 'TT_ROUTE':
case 'DTC_SETTLEMENT':
case 'DTCC_NSCC':
case 'DTCC_FICC':
case 'SWIFT_GPI':
case 'MOJALOOP':
case 'RTGS':
case 'CARD_NETWORK':
fxRate = new Decimal(1);
break;
default:
fxRate = new Decimal(1);
}
@@ -250,6 +270,17 @@ export class QuantumTranslationService {
// Private bank has higher risk
riskScore = new Decimal(0.15);
break;
case 'KTT_EVIDENCE':
case 'TT_ROUTE':
case 'DTC_SETTLEMENT':
case 'DTCC_NSCC':
case 'DTCC_FICC':
case 'SWIFT_GPI':
case 'MOJALOOP':
case 'RTGS':
case 'CARD_NETWORK':
riskScore = new Decimal(0.1);
break;
}
// Additional risk factors from transaction data

View File

@@ -147,6 +147,11 @@ import ilcRoutes from '@/core/ledger/ilc/ilc.routes';
const app: Express = express();
// Behind NPM / load balancer: set TRUST_PROXY=1 so rate limits and req.ip use the client address
if (process.env.TRUST_PROXY === '1' || process.env.TRUST_PROXY === 'true') {
app.set('trust proxy', 1);
}
// Security middleware
app.use(helmet());
@@ -220,8 +225,19 @@ const swaggerOptions = {
const swaggerSpec = swaggerJsdoc(swaggerOptions);
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
// Health check endpoint (no auth required)
app.get('/health', async (req, res) => {
// Top-level service metadata so API hostnames return a clean 200 at "/".
app.get('/', (req, res) => {
res.status(200).json({
service: 'dbis-core-banking-system',
status: 'healthy',
version: '1.0.0',
docs: '/api-docs',
health: '/health',
});
});
// Health check endpoints (no auth required)
app.get(['/health', '/v1/health'], async (req, res) => {
const healthStatus: {
status: string;
timestamp: string;
@@ -478,4 +494,3 @@ app.use('/', metricsRoutes);
app.use(errorHandler);
export default app;

View File

@@ -121,3 +121,5 @@ declare module './auth.middleware' {
}
}
/** Default admin gate for IRU routes that import this symbol (see requireAdminPermission for specific checks). */
export const adminPermissionMiddleware = requireAdminPermission(AdminPermission.VIEW_GLOBAL_OVERVIEW);

View File

@@ -52,3 +52,52 @@ export function dynamicRateLimitMiddleware(
limiter(req, res, next);
}
function iruMarketplaceRateLimitNoop(_req: Request, _res: Response, next: NextFunction): void {
next();
}
/** POST /iru/marketplace/inquiries — default 10 / 15m per IP. IRU_MARKETPLACE_RATE_LIMIT_IN_TEST=1 disables in Jest. */
export function createIruMarketplaceInquiryPostLimiter() {
if (process.env.IRU_MARKETPLACE_RATE_LIMIT_IN_TEST === '1') {
return iruMarketplaceRateLimitNoop;
}
const windowMs = parseInt(process.env.IRU_MARKETPLACE_INQUIRY_WINDOW_MS || '900000', 10);
const max = parseInt(process.env.IRU_MARKETPLACE_INQUIRY_MAX || '10', 10);
return rateLimit({
windowMs,
max,
standardHeaders: true,
legacyHeaders: false,
message: {
success: false,
error: {
code: 'RATE_LIMIT_EXCEEDED',
message: `Too many inquiry submissions. Try again after ${Math.ceil(windowMs / 60000)} minutes.`,
},
timestamp: new Date(),
},
});
}
/** Public GETs (offerings, inquiry status, pricing) — default 200 / min per IP */
export function createIruMarketplacePublicReadLimiter() {
if (process.env.IRU_MARKETPLACE_RATE_LIMIT_IN_TEST === '1') {
return iruMarketplaceRateLimitNoop;
}
const windowMs = parseInt(process.env.IRU_MARKETPLACE_PUBLIC_WINDOW_MS || '60000', 10);
const max = parseInt(process.env.IRU_MARKETPLACE_PUBLIC_MAX || '200', 10);
return rateLimit({
windowMs,
max,
standardHeaders: true,
legacyHeaders: false,
message: {
success: false,
error: {
code: 'RATE_LIMIT_EXCEEDED',
message: 'Too many requests. Please wait and retry.',
},
timestamp: new Date(),
},
});
}

View File

@@ -46,7 +46,13 @@ export function validateRequest(schema: {
errors,
});
throw new DbisError(ErrorCode.VALIDATION_ERROR, `Validation failed: ${errors.map((e) => e.message).join(', ')}`);
next(
new DbisError(
ErrorCode.VALIDATION_ERROR,
`Validation failed: ${errors.map((e) => e.message).join(', ')}`
)
);
return;
}
next(error);

View File

@@ -0,0 +1,129 @@
// Public IRU marketplace routes only (no inquiry/offering admin services) — safe for focused HTTP tests.
import { Router } from 'express';
import { marketplaceService } from '@/core/iru/marketplace.service';
import { validateRequest, iruValidationSchemas } from '@/integration/api-gateway/middleware/validation.middleware';
import {
createIruMarketplaceInquiryPostLimiter,
createIruMarketplacePublicReadLimiter,
} from '@/integration/api-gateway/middleware/rate-limit.middleware';
import type { InquiryRequest } from '@/core/iru/marketplace.service';
const router = Router();
const iruInquiryPostLimiter = createIruMarketplaceInquiryPostLimiter();
const iruPublicReadLimiter = createIruMarketplacePublicReadLimiter();
router.get('/offerings', iruPublicReadLimiter, async (req, res, next) => {
try {
const filters: Record<string, unknown> = {};
if (req.query.capacityTier) {
filters.capacityTier = parseInt(req.query.capacityTier as string, 10);
}
if (req.query.institutionalType) {
filters.institutionalType = req.query.institutionalType as string;
}
if (req.query.status) {
filters.status = req.query.status as string;
}
const offerings = await marketplaceService.getOfferings(
filters as Parameters<typeof marketplaceService.getOfferings>[0]
);
res.json({
success: true,
data: offerings,
timestamp: new Date(),
});
} catch (error) {
return next(error);
}
});
router.get('/offerings/:offeringId/pricing', iruPublicReadLimiter, async (req, res, next) => {
try {
const { offeringId } = req.params;
let usageProfile: unknown;
if (req.query.usageProfile != null && String(req.query.usageProfile).trim() !== '') {
try {
usageProfile = JSON.parse(req.query.usageProfile as string);
} catch {
return res.status(400).json({
success: false,
error: {
code: 'INVALID_JSON',
message: 'usageProfile query parameter must be valid JSON',
},
timestamp: new Date(),
});
}
}
const pricing = await marketplaceService.calculatePricing(offeringId, usageProfile);
res.json({
success: true,
data: pricing,
timestamp: new Date(),
});
} catch (error) {
return next(error);
}
});
router.get('/offerings/:offeringId', iruPublicReadLimiter, async (req, res, next) => {
try {
const { offeringId } = req.params;
const offering = await marketplaceService.getOfferingById(offeringId);
if (!offering) {
return res.status(404).json({
success: false,
error: {
code: 'NOT_FOUND',
message: `Offering ${offeringId} not found`,
},
timestamp: new Date(),
});
}
res.json({
success: true,
data: offering,
timestamp: new Date(),
});
} catch (error) {
return next(error);
}
});
router.post(
'/inquiries',
iruInquiryPostLimiter,
validateRequest({ body: iruValidationSchemas.inquiryRequest }),
async (req, res, next) => {
try {
const result = await marketplaceService.submitInquiry(req.body as InquiryRequest);
res.status(201).json({
success: true,
data: result,
timestamp: new Date(),
});
} catch (error) {
return next(error);
}
}
);
router.get('/inquiries/:inquiryId', iruPublicReadLimiter, async (req, res, next) => {
try {
const { inquiryId } = req.params;
const inquiry = await marketplaceService.getInquiryStatusPublic(inquiryId);
res.json({
success: true,
data: inquiry,
timestamp: new Date(),
});
} catch (error) {
return next(error);
}
});
export default router;

View File

@@ -1,152 +1,20 @@
// IRU Marketplace API Routes
// Routes for Sankofa Phoenix Marketplace
// IRU Marketplace API — public routes + admin (inquiry/offering services).
import { Router } from 'express';
import { marketplaceService } from '@/core/iru/marketplace.service';
import iruMarketplacePublicRoutes from '@/integration/api-gateway/routes/iru-marketplace-public.routes';
import { offeringService } from '@/core/iru/offering.service';
import { inquiryService } from '@/core/iru/inquiry.service';
import { zeroTrustAuthMiddleware } from '@/integration/api-gateway/middleware/auth.middleware';
import { adminPermissionMiddleware } from '@/integration/api-gateway/middleware/admin-permission.middleware';
import { validateRequest, iruValidationSchemas } from '@/integration/api-gateway/middleware/validation.middleware';
const router = Router();
// ============================================================================
// Public Marketplace Routes (No Auth Required)
// ============================================================================
/**
* @route GET /api/v1/iru/marketplace/offerings
* @desc Get all active IRU offerings
* @access Public
*/
router.get('/offerings', async (req, res, next) => {
try {
const filters: any = {};
if (req.query.capacityTier) {
filters.capacityTier = parseInt(req.query.capacityTier as string);
}
if (req.query.institutionalType) {
filters.institutionalType = req.query.institutionalType as string;
}
if (req.query.status) {
filters.status = req.query.status as string;
}
const offerings = await marketplaceService.getOfferings(filters);
res.json({
success: true,
data: offerings,
timestamp: new Date(),
});
} catch (error) {
return next(error);
}
});
/**
* @route GET /api/v1/iru/marketplace/offerings/:offeringId
* @desc Get offering by ID
* @access Public
*/
router.get('/offerings/:offeringId', async (req, res, next) => {
try {
const { offeringId } = req.params;
const offering = await marketplaceService.getOfferingById(offeringId);
if (!offering) {
return res.status(404).json({
success: false,
error: {
code: 'NOT_FOUND',
message: `Offering ${offeringId} not found`,
},
timestamp: new Date(),
});
}
res.json({
success: true,
data: offering,
timestamp: new Date(),
});
} catch (error) {
return next(error);
}
});
/**
* @route POST /api/v1/iru/marketplace/inquiries
* @desc Submit initial inquiry
* @access Public
*/
router.post(
'/inquiries',
validateRequest({ body: iruValidationSchemas.inquiryRequest }),
async (req, res, next) => {
try {
const result = await marketplaceService.submitInquiry(req.body);
res.status(201).json({
success: true,
data: result,
timestamp: new Date(),
});
} catch (error) {
return next(error);
}
}
);
/**
* @route GET /api/v1/iru/marketplace/inquiries/:inquiryId
* @desc Get inquiry status
* @access Public (with inquiry ID)
*/
router.get('/inquiries/:inquiryId', async (req, res, next) => {
try {
const { inquiryId } = req.params;
const inquiry = await marketplaceService.getInquiryStatus(inquiryId);
res.json({
success: true,
data: inquiry,
timestamp: new Date(),
});
} catch (error) {
return next(error);
}
});
/**
* @route GET /api/v1/iru/marketplace/offerings/:offeringId/pricing
* @desc Calculate pricing for an offering
* @access Public
*/
router.get('/offerings/:offeringId/pricing', async (req, res, next) => {
try {
const { offeringId } = req.params;
const usageProfile = req.query.usageProfile
? JSON.parse(req.query.usageProfile as string)
: undefined;
const pricing = await marketplaceService.calculatePricing(offeringId, usageProfile);
res.json({
success: true,
data: pricing,
timestamp: new Date(),
});
} catch (error) {
return next(error);
}
});
router.use(iruMarketplacePublicRoutes);
// ============================================================================
// Admin Routes (Auth Required)
// ============================================================================
/**
* @route POST /api/v1/iru/marketplace/admin/offerings
* @desc Create new IRU offering
* @access Admin
*/
router.post(
'/admin/offerings',
zeroTrustAuthMiddleware,
@@ -165,11 +33,6 @@ router.post(
}
);
/**
* @route PUT /api/v1/iru/marketplace/admin/offerings/:offeringId
* @desc Update IRU offering
* @access Admin
*/
router.put(
'/admin/offerings/:offeringId',
zeroTrustAuthMiddleware,
@@ -189,11 +52,6 @@ router.put(
}
);
/**
* @route DELETE /api/v1/iru/marketplace/admin/offerings/:offeringId
* @desc Delete IRU offering
* @access Admin
*/
router.delete(
'/admin/offerings/:offeringId',
zeroTrustAuthMiddleware,
@@ -213,11 +71,6 @@ router.delete(
}
);
/**
* @route GET /api/v1/iru/marketplace/admin/offerings/:offeringId/stats
* @desc Get offering statistics
* @access Admin
*/
router.get(
'/admin/offerings/:offeringId/stats',
zeroTrustAuthMiddleware,
@@ -237,18 +90,13 @@ router.get(
}
);
/**
* @route GET /api/v1/iru/marketplace/admin/inquiries
* @desc Get all inquiries with filters
* @access Admin
*/
router.get(
'/admin/inquiries',
zeroTrustAuthMiddleware,
adminPermissionMiddleware,
async (req, res, next) => {
try {
const filters: any = {};
const filters: Record<string, unknown> = {};
if (req.query.status) {
filters.status = req.query.status as string;
}
@@ -256,13 +104,13 @@ router.get(
filters.offeringId = req.query.offeringId as string;
}
if (req.query.capacityTier) {
filters.capacityTier = parseInt(req.query.capacityTier as string);
filters.capacityTier = parseInt(req.query.capacityTier as string, 10);
}
if (req.query.limit) {
filters.limit = parseInt(req.query.limit as string);
filters.limit = parseInt(req.query.limit as string, 10);
}
if (req.query.offset) {
filters.offset = parseInt(req.query.offset as string);
filters.offset = parseInt(req.query.offset as string, 10);
}
const inquiries = await inquiryService.getInquiries(filters);
@@ -277,11 +125,6 @@ router.get(
}
);
/**
* @route GET /api/v1/iru/marketplace/admin/inquiries/:inquiryId
* @desc Get inquiry by ID with full details
* @access Admin
*/
router.get(
'/admin/inquiries/:inquiryId',
zeroTrustAuthMiddleware,
@@ -301,11 +144,6 @@ router.get(
}
);
/**
* @route POST /api/v1/iru/marketplace/admin/inquiries/:inquiryId/acknowledge
* @desc Acknowledge inquiry
* @access Admin
*/
router.post(
'/admin/inquiries/:inquiryId/acknowledge',
zeroTrustAuthMiddleware,
@@ -325,11 +163,6 @@ router.post(
}
);
/**
* @route PUT /api/v1/iru/marketplace/admin/inquiries/:inquiryId
* @desc Update inquiry
* @access Admin
*/
router.put(
'/admin/inquiries/:inquiryId',
zeroTrustAuthMiddleware,