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:
2
src/__tests__/gateway-http-env-setup.ts
Normal file
2
src/__tests__/gateway-http-env-setup.ts
Normal 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';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
15
src/__tests__/utils/gateway-http-test-app.ts
Normal file
15
src/__tests__/utils/gateway-http-test-app.ts
Normal 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;
|
||||
}
|
||||
15
src/__tests__/utils/iru-marketplace-http-test-app.ts
Normal file
15
src/__tests__/utils/iru-marketplace-http-test-app.ts
Normal 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;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from './debank-portfolio.service.js';
|
||||
export * from './debank-portfolio.service';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
13
src/core/gateway/adapters/README.md
Normal file
13
src/core/gateway/adapters/README.md
Normal 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.
|
||||
55
src/core/gateway/adapters/gateway-adapter-registry.ts
Normal file
55
src/core/gateway/adapters/gateway-adapter-registry.ts
Normal 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);
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
17
src/core/gateway/rails/README.md
Normal file
17
src/core/gateway/rails/README.md
Normal 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`.
|
||||
42
src/core/gateway/rails/gateway-rails-audit.ts
Normal file
42
src/core/gateway/rails/gateway-rails-audit.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
124
src/core/gateway/rails/gateway-rails-enforcement.ts
Normal file
124
src/core/gateway/rails/gateway-rails-enforcement.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: [],
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
46
src/infrastructure/quantum/proxy/legacy-protocol-types.ts
Normal file
46
src/infrastructure/quantum/proxy/legacy-protocol-types.ts
Normal 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;
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user