feat: add member portal and auth hardening

This commit is contained in:
defiQUG
2026-04-18 12:05:17 -07:00
parent c80b2a543a
commit 468bc05b78
59 changed files with 4066 additions and 604 deletions

View File

@@ -1,48 +1,53 @@
# DBIS Admin Console - Login Credentials & Endpoints # DBIS Portal Login Credentials & Endpoints
**Last Updated:** 2025-01-22 **Last Updated:** 2026-04-15
--- ---
## 🔐 Login Credentials ## Login Credentials
### Current Authentication Status ### Current Authentication Status
**⚠️ Mock Authentication Active** The portals now use the backend `/api/auth/*` endpoints. The old mock-auth behavior is no longer active.
The frontend is currently using **mock authentication** for development. This means: There are now two supported login patterns:
- `core.d-bis.org` and `admin.d-bis.org` use employee-backed portal auth.
- **Any username and password will work** - `secure.d-bis.org` uses member-backed portal auth.
- The system accepts any credentials and creates a mock admin user
- No actual backend authentication is performed yet
### Mock User Details
When you log in with any credentials, you'll receive:
```json
{
"id": "1",
"employeeId": "emp-001",
"name": "Admin User",
"email": "<your-username>",
"role": "DBIS_Super_Admin",
"permissions": ["all"]
}
```
### Login Instructions ### Login Instructions
1. **Go to:** http://192.168.11.130/login 1. Go to the portal surface you need:
2. **Enter any username** (e.g., `admin`, `test`, `user`) - `https://core.d-bis.org/login`
3. **Enter any password** (e.g., `password`, `123456`, `admin`) - `https://admin.d-bis.org/login`
4. **Click "Sign In"** - `https://secure.d-bis.org/login`
2. Enter the username for that surface.
3. Enter the matching secret or credential.
4. Click `Sign In`.
5. For `core` and `admin`, enter the 6-digit authenticator code when prompted if MFA is enabled on the employee account.
**Note:** The login form requires both fields to be filled, but the values don't matter - any combination will work. ### Credential Rules
#### Employee-backed surfaces: `core` and `admin`
The username must match an active `employee_credentials` record by employee ID or email.
The password must match the employee's stored `portalPasswordHash` credential.
If MFA is enabled, the login flow requires a valid TOTP code after the password step.
#### Member surface: `secure`
The username must match an active `portal_member_accounts` record by member ID or email.
The password must match the member account's stored `portalPasswordHash` credential.
The member account must also be `approved` and linked to either:
- a live participant record with GLEIF-backed LEI validation, or
- a stored institution snapshot containing a registry-validated LEI, institution name, and country.
--- ---
## 🌐 Frontend Routes (Client-Side) ## Frontend Routes (Client-Side)
### Public Routes ### Public Routes
@@ -78,7 +83,7 @@ When you log in with any credentials, you'll receive:
--- ---
## 🔌 Backend API Endpoints ## Backend API Endpoints
### Base URL ### Base URL
@@ -90,9 +95,23 @@ When you log in with any credentials, you'll receive:
| Method | Endpoint | Description | Status | | Method | Endpoint | Description | Status |
|--------|----------|-------------|--------| |--------|----------|-------------|--------|
| `POST` | `/api/auth/login` | User login | ⚠️ Not implemented (using mock) | | `POST` | `/api/auth/login` | Portal login | Live |
| `POST` | `/api/auth/logout` | User logout | ⚠️ Not implemented (using mock) | | `POST` | `/api/auth/logout` | Portal logout | Live |
| `POST` | `/api/auth/refresh` | Refresh token | ⚠️ Not implemented | | `GET` | `/api/auth/me` | Resolve current portal user from token | Live |
| `POST` | `/api/auth/password/change` | Authenticated password rotation | Live |
| `POST` | `/api/auth/password/reset/request` | Record password reset request | Live |
| `POST` | `/api/auth/password/reset/complete` | Complete reset with one-time token | Live |
| `GET` | `/api/auth/mfa/status` | Employee MFA status | Live |
| `POST` | `/api/auth/mfa/setup` | Generate employee MFA enrollment secret | Live |
| `POST` | `/api/auth/mfa/enable` | Enable employee MFA | Live |
| `POST` | `/api/auth/mfa/disable` | Disable employee MFA | Live |
| `POST` | `/api/auth/admin/accounts/employee` | Issue or update employee portal account | Live |
| `GET` | `/api/auth/admin/accounts/member` | List member portal accounts | Live |
| `POST` | `/api/auth/admin/accounts/member` | Issue member portal account | Live |
| `POST` | `/api/auth/admin/accounts/member/:memberId/approve` | Approve member portal account | Live |
| `POST` | `/api/auth/admin/password-reset/issue` | Issue one-time reset token | Live |
| `POST` | `/api/auth/admin/accounts/deactivate` | Deactivate employee or member account | Live |
| `POST` | `/api/auth/refresh` | Refresh token | Not implemented |
### DBIS Admin API Endpoints ### DBIS Admin API Endpoints
@@ -203,10 +222,13 @@ When you log in with any credentials, you'll receive:
### Current Implementation ### Current Implementation
- **Type:** Mock authentication (development mode) - **Type:** Live backend-backed portal authentication
- **Token Storage:** `sessionStorage` (cleared on tab close) - **Token Storage:** `sessionStorage` (cleared on tab close)
- **Token Format:** `SOV-TOKEN <token>` - **Token Format:** `SOV-TOKEN <token>`
- **Token Header:** `Authorization: SOV-TOKEN <token>` - **Token Header:** `Authorization: SOV-TOKEN <token>`
- **Employee MFA:** TOTP for `core` and `admin` when enabled on the employee record
- **Lockout Policy:** Failed login attempts trigger temporary account lockout
- **Password Lifecycle:** Change-password, admin-issued reset token, and reset completion flows are available
### Request Headers ### Request Headers
@@ -230,16 +252,18 @@ Content-Type: application/json
--- ---
## 📍 Quick Reference ## Quick Reference
### Login ### Login
- **URL:** http://192.168.11.130/login - **Core:** `https://core.d-bis.org/login`
- **Credentials:** Any username/password combination - **Admin:** `https://admin.d-bis.org/login`
- **After Login:** Redirects to `/dbis/overview` - **Member:** `https://secure.d-bis.org/login`
- **After Login:** Redirects to the runtime portal home route
### Main Dashboards ### Main Dashboards
- **DBIS Overview:** http://192.168.11.130/dbis/overview - **Core Overview:** `https://core.d-bis.org/`
- **SCB Overview:** http://192.168.11.130/scb/overview - **Admin Overview:** `https://admin.d-bis.org/`
- **Member Overview:** `https://secure.d-bis.org/`
### API Base URL ### API Base URL
- **Default:** `http://192.168.11.150:3000` - **Default:** `http://192.168.11.150:3000`
@@ -247,26 +271,18 @@ Content-Type: application/json
--- ---
## ⚠️ Important Notes ## Important Notes
1. **Mock Authentication:** Currently using mock auth - any credentials work 1. **Real portal auth:** The frontend calls the backend auth routes and no longer accepts arbitrary credentials.
2. **Backend Required:** Most API endpoints require a running backend 2. **Backend required:** Portal login depends on the live DBIS API.
3. **Token Format:** Uses `SOV-TOKEN` prefix (not standard `Bearer`) 3. **Token format:** Portal sessions use JWT bearer tokens.
4. **Session Storage:** Tokens stored in `sessionStorage` (not `localStorage`) 4. **Session storage:** Tokens and user state are kept in `sessionStorage`.
5. **Auto-Logout:** Session clears when browser tab closes 5. **Member surface:** `secure.d-bis.org` uses the member shared-secret login path.
--- ---
## 🔄 Next Steps ## Next Steps
To enable real authentication: 1. Replace shared-secret employee bootstrap access with individually managed credentials only.
2. Add token refresh or httpOnly cookie sessions.
1. Implement backend `/api/auth/login` endpoint 3. Add role-specific operator runbooks for issuing portal accounts.
2. Update `authService.ts` to call real API
3. Configure JWT token validation
4. Set up proper user roles and permissions
5. Remove mock authentication code
---
**For development/testing:** Use any username and password to log in.

View File

@@ -4,11 +4,10 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DBIS Admin Console</title> <title>DBIS Institutional Portal</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

View File

@@ -1,4 +1,4 @@
import { lazy, Suspense } from 'react'; import { lazy, Suspense, useEffect } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom'; import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuthStore } from './stores/authStore'; import { useAuthStore } from './stores/authStore';
import ProtectedRoute from './components/auth/ProtectedRoute'; import ProtectedRoute from './components/auth/ProtectedRoute';
@@ -6,10 +6,12 @@ import ErrorBoundary from './components/shared/ErrorBoundary';
import PageError from './components/shared/PageError'; import PageError from './components/shared/PageError';
import LoadingSpinner from './components/shared/LoadingSpinner'; import LoadingSpinner from './components/shared/LoadingSpinner';
import SkipLink from './components/shared/SkipLink'; import SkipLink from './components/shared/SkipLink';
import { runtimePortal } from './config/runtimePortal';
// Layout components (loaded immediately as they're always needed) // Layout components (loaded immediately as they're always needed)
import DBISLayout from './components/layout/DBISLayout'; import DBISLayout from './components/layout/DBISLayout';
import SCBLayout from './components/layout/SCBLayout'; import SCBLayout from './components/layout/SCBLayout';
import MemberPortalLayout from './components/layout/MemberPortalLayout';
// Auth page (loaded immediately for faster login) // Auth page (loaded immediately for faster login)
import LoginPage from './pages/auth/LoginPage'; import LoginPage from './pages/auth/LoginPage';
@@ -28,6 +30,11 @@ const DBISRiskCompliancePage = lazy(() => import('./pages/dbis/RiskCompliancePag
const SCBOverviewPage = lazy(() => import('./pages/scb/OverviewPage')); const SCBOverviewPage = lazy(() => import('./pages/scb/OverviewPage'));
const SCBFIManagementPage = lazy(() => import('./pages/scb/FIManagementPage')); const SCBFIManagementPage = lazy(() => import('./pages/scb/FIManagementPage'));
const SCBCorridorPolicyPage = lazy(() => import('./pages/scb/CorridorPolicyPage')); const SCBCorridorPolicyPage = lazy(() => import('./pages/scb/CorridorPolicyPage'));
const MemberOverviewPage = lazy(() => import('./pages/portal/MemberOverviewPage'));
const MemberAccessPage = lazy(() => import('./pages/portal/MemberAccessPage'));
const MemberDocumentsPage = lazy(() => import('./pages/portal/MemberDocumentsPage'));
const MemberStatusPage = lazy(() => import('./pages/portal/MemberStatusPage'));
const MemberSupportPage = lazy(() => import('./pages/portal/MemberSupportPage'));
/** /**
* Lazy-loaded route wrapper with Suspense fallback * Lazy-loaded route wrapper with Suspense fallback
@@ -39,102 +46,158 @@ const LazyRoute = ({ children }: { children: React.ReactNode }) => (
function App() { function App() {
const { isAuthenticated } = useAuthStore(); const { isAuthenticated } = useAuthStore();
useEffect(() => {
document.title = runtimePortal.pageTitle;
}, []);
return ( return (
<ErrorBoundary> <ErrorBoundary>
<SkipLink /> <SkipLink />
<Routes> <Routes>
<Route path="/login" element={!isAuthenticated ? <LoginPage /> : <Navigate to="/dbis/overview" replace />} /> <Route path="/login" element={!isAuthenticated ? <LoginPage /> : <Navigate to={runtimePortal.homeRoute} replace />} />
<Route element={<ProtectedRoute />}> <Route element={<ProtectedRoute />}>
<Route path="/dbis/*" element={<DBISLayout />}> {runtimePortal.isMember ? (
<Route <>
path="overview" <Route path="/portal/*" element={<MemberPortalLayout />}>
element={ <Route
<LazyRoute> path="overview"
<DBISOverviewPage /> element={
</LazyRoute> <LazyRoute>
} <MemberOverviewPage />
/> </LazyRoute>
<Route }
path="participants" />
element={ <Route
<LazyRoute> path="access"
<DBISParticipantsPage /> element={
</LazyRoute> <LazyRoute>
} <MemberAccessPage />
/> </LazyRoute>
<Route }
path="gru" />
element={ <Route
<LazyRoute> path="documents"
<DBISGRUPage /> element={
</LazyRoute> <LazyRoute>
} <MemberDocumentsPage />
/> </LazyRoute>
<Route }
path="gas-qps" />
element={ <Route
<LazyRoute> path="status"
<DBISGASQPSPage /> element={
</LazyRoute> <LazyRoute>
} <MemberStatusPage />
/> </LazyRoute>
<Route }
path="cbdc-fx" />
element={ <Route
<LazyRoute> path="support"
<DBISCBDCFXPage /> element={
</LazyRoute> <LazyRoute>
} <MemberSupportPage />
/> </LazyRoute>
<Route }
path="metaverse-edge" />
element={ <Route index element={<Navigate to="overview" replace />} />
<LazyRoute> </Route>
<DBISMetaverseEdgePage />
</LazyRoute> <Route path="/" element={<Navigate to="/portal/overview" replace />} />
} </>
/> ) : (
<Route <>
path="risk-compliance" <Route path="/dbis/*" element={<DBISLayout />}>
element={ <Route
<LazyRoute> path="overview"
<DBISRiskCompliancePage /> element={
</LazyRoute> <LazyRoute>
} <DBISOverviewPage />
/> </LazyRoute>
<Route index element={<Navigate to="overview" replace />} /> }
</Route> />
<Route
<Route path="/scb/*" element={<SCBLayout />}> path="participants"
<Route element={
path="overview" <LazyRoute>
element={ <DBISParticipantsPage />
<LazyRoute> </LazyRoute>
<SCBOverviewPage /> }
</LazyRoute> />
} <Route
/> path="gru"
<Route element={
path="fi-management" <LazyRoute>
element={ <DBISGRUPage />
<LazyRoute> </LazyRoute>
<SCBFIManagementPage /> }
</LazyRoute> />
} <Route
/> path="gas-qps"
<Route element={
path="corridors" <LazyRoute>
element={ <DBISGASQPSPage />
<LazyRoute> </LazyRoute>
<SCBCorridorPolicyPage /> }
</LazyRoute> />
} <Route
/> path="cbdc-fx"
<Route index element={<Navigate to="overview" replace />} /> element={
</Route> <LazyRoute>
<DBISCBDCFXPage />
<Route path="/" element={<Navigate to="/dbis/overview" replace />} /> </LazyRoute>
}
/>
<Route
path="metaverse-edge"
element={
<LazyRoute>
<DBISMetaverseEdgePage />
</LazyRoute>
}
/>
<Route
path="risk-compliance"
element={
<LazyRoute>
<DBISRiskCompliancePage />
</LazyRoute>
}
/>
<Route index element={<Navigate to="overview" replace />} />
</Route>
<Route path="/scb/*" element={<SCBLayout />}>
<Route
path="overview"
element={
<LazyRoute>
<SCBOverviewPage />
</LazyRoute>
}
/>
<Route
path="fi-management"
element={
<LazyRoute>
<SCBFIManagementPage />
</LazyRoute>
}
/>
<Route
path="corridors"
element={
<LazyRoute>
<SCBCorridorPolicyPage />
</LazyRoute>
}
/>
<Route index element={<Navigate to="overview" replace />} />
</Route>
<Route path="/" element={<Navigate to="/dbis/overview" replace />} />
</>
)}
</Route> </Route>
<Route path="/404" element={<PageError code={404} />} /> <Route path="/404" element={<PageError code={404} />} />
@@ -147,4 +210,3 @@ function App() {
} }
export default App; export default App;

View File

@@ -33,16 +33,17 @@ const dbisNavItems = [
export default function DBISLayout() { export default function DBISLayout() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const handleToggleSidebar = () => setSidebarCollapsed((current) => !current);
return ( return (
<div className="layout"> <div className="layout">
<SidebarNavigation <SidebarNavigation
items={dbisNavItems} items={dbisNavItems}
collapsed={sidebarCollapsed} collapsed={sidebarCollapsed}
onToggle={() => setSidebarCollapsed(!sidebarCollapsed)} onToggle={handleToggleSidebar}
/> />
<div className="layout__main"> <div className="layout__main">
<TopBar /> <TopBar sidebarCollapsed={sidebarCollapsed} onToggleSidebar={handleToggleSidebar} />
<main id="main-content" className="layout__content" role="main"> <main id="main-content" className="layout__content" role="main">
<Outlet /> <Outlet />
</main> </main>
@@ -50,4 +51,3 @@ export default function DBISLayout() {
</div> </div>
); );
} }

View File

@@ -0,0 +1,34 @@
import { Outlet } from 'react-router-dom';
import { useState } from 'react';
import { MdAssignmentTurnedIn, MdDashboard, MdFolderShared, MdHealthAndSafety, MdSupportAgent } from 'react-icons/md';
import SidebarNavigation from './SidebarNavigation';
import TopBar from './TopBar';
const memberNavItems = [
{ path: '/portal/overview', label: 'Overview', icon: <MdDashboard /> },
{ path: '/portal/access', label: 'Access & Requests', icon: <MdAssignmentTurnedIn /> },
{ path: '/portal/documents', label: 'Documents & Reporting', icon: <MdFolderShared /> },
{ path: '/portal/status', label: 'Status & Support', icon: <MdHealthAndSafety /> },
{ path: '/portal/support', label: 'Support Channels', icon: <MdSupportAgent /> },
];
export default function MemberPortalLayout() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const handleToggleSidebar = () => setSidebarCollapsed((current) => !current);
return (
<div className="layout">
<SidebarNavigation
items={memberNavItems}
collapsed={sidebarCollapsed}
onToggle={handleToggleSidebar}
/>
<div className="layout__main">
<TopBar sidebarCollapsed={sidebarCollapsed} onToggleSidebar={handleToggleSidebar} />
<main id="main-content" className="layout__content" role="main">
<Outlet />
</main>
</div>
</div>
);
}

View File

@@ -27,16 +27,17 @@ const scbNavItems = [
export default function SCBLayout() { export default function SCBLayout() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const handleToggleSidebar = () => setSidebarCollapsed((current) => !current);
return ( return (
<div className="layout"> <div className="layout">
<SidebarNavigation <SidebarNavigation
items={scbNavItems} items={scbNavItems}
collapsed={sidebarCollapsed} collapsed={sidebarCollapsed}
onToggle={() => setSidebarCollapsed(!sidebarCollapsed)} onToggle={handleToggleSidebar}
/> />
<div className="layout__main"> <div className="layout__main">
<TopBar /> <TopBar sidebarCollapsed={sidebarCollapsed} onToggleSidebar={handleToggleSidebar} />
<main id="main-content" className="layout__content" role="main"> <main id="main-content" className="layout__content" role="main">
<Outlet /> <Outlet />
</main> </main>
@@ -44,4 +45,3 @@ export default function SCBLayout() {
</div> </div>
); );
} }

View File

@@ -3,6 +3,7 @@ import { NavLink } from 'react-router-dom';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import { useAuthStore } from '@/stores/authStore'; import { useAuthStore } from '@/stores/authStore';
import { runtimePortal } from '@/config/runtimePortal';
import './SidebarNavigation.css'; import './SidebarNavigation.css';
interface NavItem { interface NavItem {
@@ -27,9 +28,15 @@ export default function SidebarNavigation({ items, collapsed = false, onToggle }
return ( return (
<nav className={clsx('sidebar', { 'sidebar--collapsed': collapsed })}> <nav className={clsx('sidebar', { 'sidebar--collapsed': collapsed })}>
<div className="sidebar__header"> <div className="sidebar__header">
<h2 className="sidebar__title">DBIS Admin</h2> <h2 className="sidebar__title">{runtimePortal.sidebarTitle}</h2>
{onToggle && ( {onToggle && !collapsed && (
<button className="sidebar__toggle" onClick={onToggle} aria-label="Toggle sidebar"> <button
type="button"
className="sidebar__toggle"
onClick={onToggle}
aria-label="Close navigation panel"
aria-expanded={!collapsed}
>
</button> </button>
)} )}
@@ -57,4 +64,3 @@ export default function SidebarNavigation({ items, collapsed = false, onToggle }
</nav> </nav>
); );
} }

View File

@@ -11,6 +11,31 @@
z-index: 50; z-index: 50;
} }
.topbar__left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.topbar__menu-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border: 1px solid var(--color-border);
border-radius: 0.75rem;
background: var(--color-bg);
color: var(--color-text);
cursor: pointer;
font-size: 1.25rem;
flex-shrink: 0;
}
.topbar__menu-toggle:hover {
background: var(--color-bg-tertiary, var(--color-bg-secondary));
}
.topbar__title { .topbar__title {
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 700; font-weight: 700;
@@ -42,3 +67,12 @@
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
@media (max-width: 767px) {
.topbar {
padding: 0 1rem;
}
.topbar__user-role {
display: none;
}
}

View File

@@ -2,9 +2,15 @@
import { useAuthStore } from '@/stores/authStore'; import { useAuthStore } from '@/stores/authStore';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import Button from '@/components/shared/Button'; import Button from '@/components/shared/Button';
import { runtimePortal } from '@/config/runtimePortal';
import './TopBar.css'; import './TopBar.css';
export default function TopBar() { interface TopBarProps {
sidebarCollapsed?: boolean;
onToggleSidebar?: () => void;
}
export default function TopBar({ sidebarCollapsed = false, onToggleSidebar }: TopBarProps) {
const { user, logout } = useAuthStore(); const { user, logout } = useAuthStore();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -16,7 +22,18 @@ export default function TopBar() {
return ( return (
<header className="topbar"> <header className="topbar">
<div className="topbar__left"> <div className="topbar__left">
<h1 className="topbar__title">DBIS Admin Console</h1> {onToggleSidebar && sidebarCollapsed && (
<button
type="button"
className="topbar__menu-toggle"
onClick={onToggleSidebar}
aria-label="Open navigation panel"
aria-expanded={!sidebarCollapsed}
>
</button>
)}
<h1 className="topbar__title">{runtimePortal.topbarTitle}</h1>
</div> </div>
<div className="topbar__right"> <div className="topbar__right">
<div className="topbar__user"> <div className="topbar__user">
@@ -30,4 +47,3 @@ export default function TopBar() {
</header> </header>
); );
} }

View File

@@ -6,6 +6,19 @@
*/ */
import { z } from 'zod'; import { z } from 'zod';
function resolveApiBaseUrl(): string {
const configured = import.meta.env.VITE_API_BASE_URL?.trim();
if (configured) {
return configured;
}
if (typeof window !== 'undefined' && window.location?.origin) {
return window.location.origin;
}
return 'http://localhost:3000';
}
const envSchema = z.object({ const envSchema = z.object({
VITE_API_BASE_URL: z.string().url('VITE_API_BASE_URL must be a valid URL'), VITE_API_BASE_URL: z.string().url('VITE_API_BASE_URL must be a valid URL'),
VITE_APP_NAME: z.string().min(1, 'VITE_APP_NAME is required'), VITE_APP_NAME: z.string().min(1, 'VITE_APP_NAME is required'),
@@ -21,7 +34,7 @@ let env: Env;
try { try {
env = envSchema.parse({ env = envSchema.parse({
VITE_API_BASE_URL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000', VITE_API_BASE_URL: resolveApiBaseUrl(),
VITE_APP_NAME: import.meta.env.VITE_APP_NAME || 'DBIS Admin Console', VITE_APP_NAME: import.meta.env.VITE_APP_NAME || 'DBIS Admin Console',
VITE_REAL_TIME_UPDATE_INTERVAL: import.meta.env.VITE_REAL_TIME_UPDATE_INTERVAL || '5000', VITE_REAL_TIME_UPDATE_INTERVAL: import.meta.env.VITE_REAL_TIME_UPDATE_INTERVAL || '5000',
}); });

View File

@@ -0,0 +1,70 @@
export type PortalSurface = 'admin' | 'member' | 'core';
const MEMBER_HOSTS = new Set(['secure.d-bis.org', 'members.d-bis.org']);
const CORE_HOSTS = new Set(['core.d-bis.org']);
function getSurfaceCopy(surface: PortalSurface) {
switch (surface) {
case 'member':
return {
pageTitle: 'DBIS Member Portal',
sidebarTitle: 'DBIS Member',
topbarTitle: 'DBIS Member Portal',
loginTitle: 'DBIS Member Portal',
loginSubtitle: 'Secure institution access for submissions, requests, reporting, and member services',
homeRoute: '/portal/overview',
};
case 'core':
return {
pageTitle: 'DBIS Core Banking Application',
sidebarTitle: 'DBIS Core',
topbarTitle: 'DBIS Core Banking',
loginTitle: 'DBIS Core Banking Application',
loginSubtitle: 'Operational access to the DBIS Core banking application and institution workflows',
homeRoute: '/dbis/overview',
};
default:
return {
pageTitle: 'DBIS Admin Console',
sidebarTitle: 'DBIS Admin',
topbarTitle: 'DBIS Admin Console',
loginTitle: 'DBIS Admin Console',
loginSubtitle: 'Operations access for DBIS and sovereign central bank administration',
homeRoute: '/dbis/overview',
};
}
}
function detectSurface(): PortalSurface {
if (typeof window === 'undefined') {
return 'admin';
}
const host = window.location.hostname.toLowerCase();
if (MEMBER_HOSTS.has(host)) {
return 'member';
}
if (CORE_HOSTS.has(host)) {
return 'core';
}
return 'admin';
}
const surface = detectSurface();
const surfaceCopy = getSurfaceCopy(surface);
export const runtimePortal = {
surface,
isAdmin: surface === 'admin',
isMember: surface === 'member',
isCore: surface === 'core',
host: typeof window === 'undefined' ? '' : window.location.hostname.toLowerCase(),
pageTitle: surfaceCopy.pageTitle,
sidebarTitle: surfaceCopy.sidebarTitle,
topbarTitle: surfaceCopy.topbarTitle,
loginTitle: surfaceCopy.loginTitle,
loginSubtitle: surfaceCopy.loginSubtitle,
homeRoute: surfaceCopy.homeRoute,
} as const;

View File

@@ -6,6 +6,7 @@ import { Toaster } from 'react-hot-toast';
import App from './App'; import App from './App';
import { useAuthStore } from './stores/authStore'; import { useAuthStore } from './stores/authStore';
import { env } from './config/env'; import { env } from './config/env';
import { runtimePortal } from './config/runtimePortal';
import { logger } from './utils/logger'; import { logger } from './utils/logger';
import { errorTracker } from './utils/errorTracking'; import { errorTracker } from './utils/errorTracking';
import './index.css'; import './index.css';
@@ -13,7 +14,7 @@ import './index.css';
// Initialize error tracking // Initialize error tracking
errorTracker.init(); errorTracker.init();
logger.info('DBIS Admin Console starting', { version: env.VITE_APP_NAME }); logger.info(`${runtimePortal.pageTitle} starting`, { version: env.VITE_APP_NAME, surface: runtimePortal.surface });
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {

View File

@@ -4,13 +4,16 @@ import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '@/stores/authStore'; import { useAuthStore } from '@/stores/authStore';
import Button from '@/components/shared/Button'; import Button from '@/components/shared/Button';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { runtimePortal } from '@/config/runtimePortal';
import './LoginPage.css'; import './LoginPage.css';
export default function LoginPage() { export default function LoginPage() {
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [otp, setOtp] = useState('');
const [rememberMe, setRememberMe] = useState(false); const [rememberMe, setRememberMe] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [mfaRequired, setMfaRequired] = useState(false);
const { login } = useAuthStore(); const { login } = useAuthStore();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -19,9 +22,18 @@ export default function LoginPage() {
setLoading(true); setLoading(true);
try { try {
await login({ username, password, rememberMe }); const result = await login({ username, password, otp, rememberMe });
toast.success('Login successful'); if (result.mfaRequired) {
navigate('/dbis/overview'); setMfaRequired(true);
toast.success('Authenticator code required');
} else {
if (result.passwordRotationRequired) {
toast.success('Login successful. Password rotation is recommended.');
} else {
toast.success('Login successful');
}
navigate(runtimePortal.homeRoute);
}
} catch (error: any) { } catch (error: any) {
toast.error(error.message || 'Login failed'); toast.error(error.message || 'Login failed');
} finally { } finally {
@@ -33,8 +45,8 @@ export default function LoginPage() {
<div className="login-page"> <div className="login-page">
<div className="login-page__container"> <div className="login-page__container">
<div className="login-page__header"> <div className="login-page__header">
<h1>DBIS Admin Console</h1> <h1>{runtimePortal.loginTitle}</h1>
<p>Sign in to your account</p> <p>{runtimePortal.loginSubtitle}</p>
</div> </div>
<form onSubmit={handleSubmit} className="login-page__form"> <form onSubmit={handleSubmit} className="login-page__form">
<div className="login-page__field"> <div className="login-page__field">
@@ -59,6 +71,22 @@ export default function LoginPage() {
autoComplete="current-password" autoComplete="current-password"
/> />
</div> </div>
{mfaRequired ? (
<div className="login-page__field">
<label htmlFor="otp">Authenticator Code</label>
<input
id="otp"
type="text"
value={otp}
onChange={(e) => setOtp(e.target.value)}
required
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
placeholder="123456"
/>
</div>
) : null}
<div className="login-page__field"> <div className="login-page__field">
<label className="login-page__checkbox"> <label className="login-page__checkbox">
<input <input
@@ -70,11 +98,10 @@ export default function LoginPage() {
</label> </label>
</div> </div>
<Button type="submit" loading={loading} fullWidth> <Button type="submit" loading={loading} fullWidth>
Sign In {mfaRequired ? 'Verify Code' : 'Sign In'}
</Button> </Button>
</form> </form>
</div> </div>
</div> </div>
); );
} }

View File

@@ -8,6 +8,7 @@ import DataTable, { Column } from '@/components/shared/DataTable';
import Button from '@/components/shared/Button'; import Button from '@/components/shared/Button';
import LoadingSpinner from '@/components/shared/LoadingSpinner'; import LoadingSpinner from '@/components/shared/LoadingSpinner';
import type { SCBStatus } from '@/types'; import type { SCBStatus } from '@/types';
import { env } from '@/config/env';
import './OverviewPage.css'; import './OverviewPage.css';
export default function OverviewPage() { export default function OverviewPage() {
@@ -38,7 +39,7 @@ export default function OverviewPage() {
{isNetworkError ? ( {isNetworkError ? (
<div> <div>
<h2>API Connection Error</h2> <h2>API Connection Error</h2>
<p>The backend API is not available at {import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'}</p> <p>The backend API is not available at {env.VITE_API_BASE_URL}</p>
<p>Please ensure the API server is running.</p> <p>Please ensure the API server is running.</p>
<Button <Button
variant="secondary" variant="secondary"

View File

@@ -0,0 +1,29 @@
const accessItems = [
'Submission intake for dossiers, supporting instruments, and controlled correspondence',
'Request management for service actions, publication support, and authenticated document retrieval',
'Institution-level access review, delegated users, and operating contact confirmation',
'Workflow status visibility for accepted, pending, returned, and closed items',
];
export default function MemberAccessPage() {
return (
<div className="space-y-6">
<section className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<h1 className="text-3xl font-semibold text-slate-900">Access & Requests</h1>
<p className="mt-3 max-w-3xl text-sm leading-6 text-slate-600">
Use the secure member portal for operational requests and protected submission workflows. This surface is intended for institutional users rather
than public browsing.
</p>
</section>
<section className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 className="text-xl font-semibold text-slate-900">Available workflow classes</h2>
<ul className="mt-4 space-y-3 text-sm leading-6 text-slate-600">
{accessItems.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</section>
</div>
);
}

View File

@@ -0,0 +1,36 @@
const documentGroups = [
{
title: 'Governance materials',
body: 'Approved circulars, directives, standards, and member notices that require authenticated distribution or acknowledgement.',
},
{
title: 'Reporting packs',
body: 'Structured reporting templates, filing instructions, submission windows, and evidence requirements for controlled workflows.',
},
{
title: 'Operational records',
body: 'Access-controlled service correspondence, request responses, and downloadable artifacts associated with member actions.',
},
];
export default function MemberDocumentsPage() {
return (
<div className="space-y-6">
<section className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<h1 className="text-3xl font-semibold text-slate-900">Documents & Reporting</h1>
<p className="mt-3 max-w-3xl text-sm leading-6 text-slate-600">
Authenticated document delivery belongs in the secure portal when distribution, acknowledgement, or participant-level access control is required.
</p>
</section>
<section className="grid gap-4 md:grid-cols-3">
{documentGroups.map((group) => (
<article key={group.title} className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-slate-900">{group.title}</h2>
<p className="mt-3 text-sm leading-6 text-slate-600">{group.body}</p>
</article>
))}
</section>
</div>
);
}

View File

@@ -0,0 +1,58 @@
const cards = [
{
title: 'Submissions',
body: 'Prepare institutional filings, controlled disclosures, corridor requests, and supporting documents in one secure workspace.',
},
{
title: 'Requests',
body: 'Track service requests, operational actions, and document retrieval across DBIS coordination teams and member support functions.',
},
{
title: 'Reporting',
body: 'Use the secure portal for incident, ethics, sanctions, audit, and data-protection reporting workflows where disclosure channels are required.',
},
];
export default function MemberOverviewPage() {
return (
<div className="space-y-6">
<section className="rounded-2xl bg-slate-900 px-8 py-10 text-white shadow-sm">
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-300">Secure surface</p>
<h1 className="mt-3 text-4xl font-semibold tracking-tight">DBIS Member Portal</h1>
<p className="mt-4 max-w-3xl text-base leading-7 text-slate-200">
Authenticated workspace for participating institutions, accredited counterparties, and approved operations teams. Use this portal for secure
submissions, controlled requests, reporting workflows, and member service coordination.
</p>
</section>
<section className="grid gap-4 md:grid-cols-3">
{cards.map((card) => (
<article key={card.title} className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-slate-900">{card.title}</h2>
<p className="mt-3 text-sm leading-6 text-slate-600">{card.body}</p>
</article>
))}
</section>
<section className="grid gap-4 lg:grid-cols-[1.4fr_1fr]">
<article className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 className="text-xl font-semibold text-slate-900">What belongs here</h2>
<ul className="mt-4 space-y-3 text-sm leading-6 text-slate-600">
<li>Institution onboarding packets and accredited user access management.</li>
<li>Secure document exchange for governance, compliance, and operational workflows.</li>
<li>Submission and request tracking with role-based access and auditable status history.</li>
<li>Member-facing notices, service health, and controlled release materials.</li>
</ul>
</article>
<article className="rounded-xl border border-slate-200 bg-slate-50 p-6 shadow-sm">
<h2 className="text-xl font-semibold text-slate-900">Current scope</h2>
<p className="mt-4 text-sm leading-6 text-slate-600">
This portal is the secure companion to the public `d-bis.org` site. Public governance and institutional reference material stay on the public
web surface; authenticated workflow activity belongs here.
</p>
</article>
</section>
</div>
);
}

View File

@@ -0,0 +1,30 @@
export default function MemberStatusPage() {
return (
<div className="space-y-6">
<section className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<h1 className="text-3xl font-semibold text-slate-900">Status & Support</h1>
<p className="mt-3 max-w-3xl text-sm leading-6 text-slate-600">
Service-health notices, scheduled maintenance windows, support escalations, and secure coordination updates for member institutions.
</p>
</section>
<section className="grid gap-4 md:grid-cols-2">
<article className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-slate-900">Service posture</h2>
<p className="mt-3 text-sm leading-6 text-slate-600">
Operational notices should summarize current availability for secure workflows, submission handling, and member support channels without exposing
internal administrative controls.
</p>
</article>
<article className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-slate-900">Escalation path</h2>
<p className="mt-3 text-sm leading-6 text-slate-600">
Use this portal surface for authenticated support coordination, incident follow-up, and member communications that should not be handled on the
public website.
</p>
</article>
</section>
</div>
);
}

View File

@@ -0,0 +1,22 @@
export default function MemberSupportPage() {
return (
<div className="space-y-6">
<section className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<h1 className="text-3xl font-semibold text-slate-900">Support Channels</h1>
<p className="mt-3 max-w-3xl text-sm leading-6 text-slate-600">
Member support is routed through authenticated channels so institution identity, request history, and access controls can be preserved.
</p>
</section>
<section className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 className="text-xl font-semibold text-slate-900">Use secure support for</h2>
<ul className="mt-4 space-y-3 text-sm leading-6 text-slate-600">
<li>Portal access and delegated user issues</li>
<li>Submission and request follow-up</li>
<li>Controlled document delivery and acknowledgement questions</li>
<li>Member service coordination that should not traverse the public site</li>
</ul>
</section>
</div>
);
}

View File

@@ -82,6 +82,10 @@ class ApiClient {
}, },
async (error: AxiosError) => { async (error: AxiosError) => {
const url = error.config?.url || ''; const url = error.config?.url || '';
const isInteractiveAuthFlow =
url.startsWith('/api/auth/login') ||
url.startsWith('/api/auth/password/reset') ||
url.startsWith('/api/auth/mfa/');
this.cancelTokenSources.delete(url); this.cancelTokenSources.delete(url);
if (axios.isCancel(error)) { if (axios.isCancel(error)) {
@@ -101,10 +105,13 @@ class ApiClient {
switch (status) { switch (status) {
case 401: case 401:
sessionStorage.removeItem('auth_token'); if (!isInteractiveAuthFlow) {
sessionStorage.removeItem('user'); sessionStorage.removeItem('auth_token');
window.location.href = '/login'; sessionStorage.removeItem('user');
toast.error(ERROR_MESSAGES.UNAUTHORIZED); sessionStorage.removeItem('auth-storage');
window.location.href = '/login';
toast.error(ERROR_MESSAGES.UNAUTHORIZED);
}
break; break;
case 403: case 403:
toast.error(ERROR_MESSAGES.FORBIDDEN); toast.error(ERROR_MESSAGES.FORBIDDEN);

View File

@@ -1,6 +1,7 @@
// Authentication Service // Authentication Service
import { apiClient } from '../api/client'; import { apiClient } from '../api/client';
import type { LoginCredentials, User } from '@/types'; import type { LoginCredentials, LoginResponse, User } from '@/types';
import { runtimePortal } from '@/config/runtimePortal';
/** /**
* Authentication Service * Authentication Service
@@ -19,33 +20,39 @@ class AuthService {
// Tokens are cleared when the browser tab/window is closed // Tokens are cleared when the browser tab/window is closed
private readonly storage = sessionStorage; private readonly storage = sessionStorage;
async login(credentials: LoginCredentials): Promise<{ user: User; token: string }> { private decodeTokenPayload(token: string): { exp?: number } | null {
// TODO: Replace with actual login endpoint when available try {
// For now, this is a placeholder that would call the backend return JSON.parse(atob(token.split('.')[1]));
// const response = await apiClient.post('/api/auth/login', credentials); } catch {
return null;
// Mock response for development }
const mockUser: User = { }
id: '1',
employeeId: 'emp-001',
name: 'Admin User',
email: credentials.username,
role: 'DBIS_Super_Admin',
permissions: ['all'],
};
const mockToken = 'mock-jwt-token'; async login(credentials: LoginCredentials): Promise<LoginResponse> {
try {
const response = await apiClient.post<LoginResponse>('/api/auth/login', {
username: credentials.username,
password: credentials.password,
...(credentials.otp?.trim() ? { otp: credentials.otp.trim() } : {}),
surface: runtimePortal.surface,
rememberMe: credentials.rememberMe,
});
this.setToken(mockToken); if (!response.mfaRequired) {
this.setUser(mockUser); this.setToken(response.token);
this.setUser(response.user);
}
return { user: mockUser, token: mockToken }; return response;
} catch (error: any) {
const message = error?.response?.data?.error?.message || error?.message || 'Login failed';
throw new Error(message);
}
} }
async logout(): Promise<void> { async logout(): Promise<void> {
try { try {
// Call logout endpoint if available await apiClient.post('/api/auth/logout');
// await apiClient.post('/api/auth/logout');
} catch (error) { } catch (error) {
// Ignore errors on logout // Ignore errors on logout
} finally { } finally {
@@ -53,6 +60,17 @@ class AuthService {
} }
} }
async getCurrentUser(): Promise<User | null> {
const token = this.getToken();
if (!token || !this.isAuthenticated()) {
return null;
}
const response = await apiClient.get<{ user: User }>('/api/auth/me');
this.setUser(response.user);
return response.user;
}
getToken(): string | null { getToken(): string | null {
try { try {
return this.storage.getItem(this.TOKEN_KEY); return this.storage.getItem(this.TOKEN_KEY);
@@ -105,13 +123,12 @@ class AuthService {
if (!token) return false; if (!token) return false;
// Check if token is expired (basic check) // Check if token is expired (basic check)
try { const payload = this.decodeTokenPayload(token);
const payload = JSON.parse(atob(token.split('.')[1])); if (!payload?.exp) {
const exp = payload.exp * 1000; // Convert to milliseconds
return Date.now() < exp;
} catch {
return false; return false;
} }
const exp = payload.exp * 1000; // Convert to milliseconds
return Date.now() < exp;
} }
async refreshToken(): Promise<string | null> { async refreshToken(): Promise<string | null> {
@@ -129,4 +146,3 @@ class AuthService {
} }
export const authService = new AuthService(); export const authService = new AuthService();

View File

@@ -1,17 +1,17 @@
// Auth Store (Zustand) // Auth Store (Zustand)
import { create } from 'zustand'; import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware'; import { createJSONStorage, devtools, persist } from 'zustand/middleware';
import { authService } from '@/services/auth/authService'; import { authService } from '@/services/auth/authService';
import type { User, LoginCredentials } from '@/types'; import type { User, LoginCredentials, LoginResponse } from '@/types';
interface AuthState { interface AuthState {
user: User | null; user: User | null;
token: string | null; token: string | null;
isAuthenticated: boolean; isAuthenticated: boolean;
isLoading: boolean; isLoading: boolean;
login: (credentials: LoginCredentials) => Promise<void>; login: (credentials: LoginCredentials) => Promise<LoginResponse>;
logout: () => Promise<void>; logout: () => Promise<void>;
initialize: () => void; initialize: () => Promise<void>;
checkPermission: (permission: string) => boolean; checkPermission: (permission: string) => boolean;
isDBISLevel: () => boolean; isDBISLevel: () => boolean;
} }
@@ -25,18 +25,29 @@ export const useAuthStore = create<AuthState>()(
isAuthenticated: false, isAuthenticated: false,
isLoading: true, isLoading: true,
initialize: () => { initialize: async () => {
const token = authService.getToken(); const token = authService.getToken();
const user = authService.getUser();
if (token && user && authService.isAuthenticated()) { if (!token || !authService.isAuthenticated()) {
authService.clearAuth();
set({
token: null,
user: null,
isAuthenticated: false,
isLoading: false,
});
return;
}
try {
const user = await authService.getCurrentUser();
set({ set({
token, token,
user, user,
isAuthenticated: true, isAuthenticated: Boolean(user),
isLoading: false, isLoading: false,
}); });
} else { } catch (error) {
authService.clearAuth(); authService.clearAuth();
set({ set({
token: null, token: null,
@@ -50,13 +61,19 @@ export const useAuthStore = create<AuthState>()(
login: async (credentials: LoginCredentials) => { login: async (credentials: LoginCredentials) => {
try { try {
set({ isLoading: true }); set({ isLoading: true });
const { user, token } = await authService.login(credentials); const response = await authService.login(credentials);
set({ if (!response.mfaRequired) {
user, const { user, token } = response;
token, set({
isAuthenticated: true, user,
isLoading: false, token,
}); isAuthenticated: true,
isLoading: false,
});
} else {
set({ isLoading: false });
}
return response;
} catch (error) { } catch (error) {
set({ isLoading: false }); set({ isLoading: false });
throw error; throw error;
@@ -87,6 +104,7 @@ export const useAuthStore = create<AuthState>()(
}), }),
{ {
name: 'auth-storage', name: 'auth-storage',
storage: createJSONStorage(() => sessionStorage),
// Only persist user data, not token (token is in sessionStorage for security) // Only persist user data, not token (token is in sessionStorage for security)
partialize: (state) => ({ partialize: (state) => ({
user: state.user, user: state.user,
@@ -97,4 +115,3 @@ export const useAuthStore = create<AuthState>()(
{ name: 'AuthStore' } { name: 'AuthStore' }
) )
); );

View File

@@ -20,9 +20,32 @@ export interface AuthState {
export interface LoginCredentials { export interface LoginCredentials {
username: string; username: string;
password: string; password: string;
otp?: string;
rememberMe?: boolean; rememberMe?: boolean;
} }
export interface LoginChallengeUser {
id: string;
employeeId: string;
name: string;
email: string;
role: string;
}
export type LoginResponse =
| {
user: User;
token: string;
mfaRequired?: false;
passwordRotationRequired?: boolean;
}
| {
mfaRequired: true;
method: 'totp';
passwordRotationRequired?: boolean;
user: LoginChallengeUser;
};
export interface ApiError { export interface ApiError {
code: string; code: string;
message: string; message: string;
@@ -114,4 +137,3 @@ export interface ParticipantInfo {
latency?: number; latency?: number;
errorRate?: number; errorRate?: number;
} }

112
package-lock.json generated
View File

@@ -31,6 +31,7 @@
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"kafkajs": "^2.2.4", "kafkajs": "^2.2.4",
"nodemailer": "^7.0.13", "nodemailer": "^7.0.13",
"redis": "^4.7.1",
"swagger-jsdoc": "^6.2.8", "swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.0", "swagger-ui-express": "^5.0.0",
"uuid": "^9.0.1", "uuid": "^9.0.1",
@@ -754,7 +755,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@babel/generator": "^7.28.5",
@@ -2198,6 +2198,71 @@
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/@redis/bloom": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz",
"integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@redis/client": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
"license": "MIT",
"dependencies": {
"cluster-key-slot": "1.1.2",
"generic-pool": "3.9.0",
"yallist": "4.0.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@redis/client/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/@redis/graph": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz",
"integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@redis/json": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz",
"integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@redis/search": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz",
"integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@redis/time-series": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz",
"integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==",
"license": "MIT",
"peerDependencies": {
"@redis/client": "^1.0.0"
}
},
"node_modules/@scarf/scarf": { "node_modules/@scarf/scarf": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
@@ -3064,7 +3129,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz",
"integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -3287,7 +3351,6 @@
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0", "@typescript-eslint/types": "6.21.0",
@@ -3470,7 +3533,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -3928,7 +3990,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.25", "baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754", "caniuse-lite": "^1.0.30001754",
@@ -4251,6 +4312,15 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1" "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
} }
}, },
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/co": { "node_modules/co": {
"version": "4.6.0", "version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@@ -4857,7 +4927,6 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1", "@eslint-community/regexpp": "^4.6.1",
@@ -5216,7 +5285,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"accepts": "~1.3.8", "accepts": "~1.3.8",
"array-flatten": "1.1.1", "array-flatten": "1.1.1",
@@ -5626,6 +5694,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/generic-pool": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz",
"integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/gensync": { "node_modules/gensync": {
"version": "1.0.0-beta.2", "version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -6262,7 +6339,6 @@
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jest/core": "^29.7.0", "@jest/core": "^29.7.0",
"@jest/types": "^29.6.3", "@jest/types": "^29.6.3",
@@ -8164,7 +8240,6 @@
"devOptional": true, "devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@prisma/engines": "5.22.0" "@prisma/engines": "5.22.0"
}, },
@@ -8362,6 +8437,23 @@
"node": ">=8.10.0" "node": ">=8.10.0"
} }
}, },
"node_modules/redis": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz",
"integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==",
"license": "MIT",
"workspaces": [
"./packages/*"
],
"dependencies": {
"@redis/bloom": "1.2.0",
"@redis/client": "1.6.1",
"@redis/graph": "1.1.1",
"@redis/json": "1.0.7",
"@redis/search": "1.2.0",
"@redis/time-series": "1.1.0"
}
},
"node_modules/require-directory": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -9411,7 +9503,6 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@cspotcode/source-map-support": "^0.8.0", "@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7", "@tsconfig/node10": "^1.0.7",
@@ -9618,7 +9709,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"

View File

@@ -60,6 +60,7 @@
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"kafkajs": "^2.2.4", "kafkajs": "^2.2.4",
"nodemailer": "^7.0.13", "nodemailer": "^7.0.13",
"redis": "^4.7.1",
"swagger-jsdoc": "^6.2.8", "swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.0", "swagger-ui-express": "^5.0.0",
"uuid": "^9.0.1", "uuid": "^9.0.1",

View File

@@ -2442,6 +2442,19 @@ model employee_credentials {
employeeName String employeeName String
email String email String
securityClearance String securityClearance String
portalPasswordHash String?
mustRotatePassword Boolean @default(false)
failedLoginAttempts Int @default(0)
lockedUntil DateTime?
lastLoginAt DateTime?
passwordChangedAt DateTime?
passwordResetTokenHash String?
passwordResetTokenExpiresAt DateTime?
mfaEnabled Boolean @default(false)
mfaSecretCiphertext String?
mfaSecretIv String?
mfaSecretTag String?
mfaEnrolledAt DateTime?
cryptographicBadgeId String? cryptographicBadgeId String?
hsmCredentialId String? hsmCredentialId String?
status String @default("active") status String @default("active")
@@ -2458,6 +2471,44 @@ model employee_credentials {
@@index([status]) @@index([status])
} }
model portal_member_accounts {
id String @id
memberId String @unique
memberName String
email String @unique
institutionName String?
institutionCountry String?
participantId String?
lei String?
sovereignBankId String?
portalPasswordHash String
approvalStatus String @default("pending")
approvedAt DateTime?
approvedByEmployeeId String?
mustRotatePassword Boolean @default(false)
failedLoginAttempts Int @default(0)
lockedUntil DateTime?
lastLoginAt DateTime?
passwordChangedAt DateTime?
passwordResetTokenHash String?
passwordResetTokenExpiresAt DateTime?
status String @default("active")
issuedAt DateTime @default(now())
expiresAt DateTime?
revokedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime
@@index([memberId])
@@index([email])
@@index([participantId])
@@index([lei])
@@index([institutionCountry])
@@index([approvalStatus])
@@index([status])
@@index([sovereignBankId])
}
model entanglement_measurements { model entanglement_measurements {
id String @id id String @id
measurementId String @unique measurementId String @unique

View File

@@ -0,0 +1,445 @@
#!/usr/bin/env node
const { PrismaClient } = require('@prisma/client');
const bcrypt = require('bcryptjs');
const { v4: uuidv4 } = require('uuid');
const prisma = new PrismaClient();
const DEFAULT_ROLES = [
{
roleName: 'DBIS_Super_Admin',
roleDescription: 'DBIS Super Admin - Full network control',
accessLevel: 'tier_1',
permissions: ['all'],
},
{
roleName: 'DBIS_Ops',
roleDescription: 'DBIS Operations - Network monitoring and operations',
accessLevel: 'tier_2',
permissions: ['network_operations', 'gas_qps_control', 'incident_management'],
},
{
roleName: 'DBIS_Risk',
roleDescription: 'DBIS Risk Officer - Risk monitoring and compliance',
accessLevel: 'tier_2',
permissions: ['risk_monitoring', 'compliance_audit', 'stress_testing'],
},
{
roleName: 'SCB_Admin',
roleDescription: 'SCB Admin - Full control over jurisdiction',
accessLevel: 'tier_3',
permissions: ['scb_administration', 'fi_management', 'corridor_management'],
},
{
roleName: 'SCB_Risk',
roleDescription: 'SCB Risk Officer - Local risk monitoring',
accessLevel: 'tier_3',
permissions: ['local_risk_monitoring', 'compliance_review'],
},
{
roleName: 'SCB_Tech',
roleDescription: 'SCB Tech/API Owner - Technical operations',
accessLevel: 'tier_3',
permissions: ['api_management', 'technical_operations'],
},
{
roleName: 'SCB_Read_Only',
roleDescription: 'SCB Read-Only - View-only access',
accessLevel: 'tier_4',
permissions: ['view_only'],
},
];
function getBootstrapEmployee() {
return {
employeeId: process.env.DBIS_BOOTSTRAP_EMPLOYEE_ID || 'dbis-core-ops-001',
employeeName: process.env.DBIS_BOOTSTRAP_EMPLOYEE_NAME || 'Core Operations User',
email: process.env.DBIS_BOOTSTRAP_EMPLOYEE_EMAIL || 'core.ops@d-bis.org',
securityClearance: process.env.DBIS_BOOTSTRAP_EMPLOYEE_CLEARANCE || 'tier_1',
roleName: process.env.DBIS_BOOTSTRAP_EMPLOYEE_ROLE || 'DBIS_Super_Admin',
};
}
function shouldBootstrapEmployee() {
return Boolean(
process.env.DBIS_BOOTSTRAP_EMPLOYEE_PASSWORD_HASH?.trim() ||
process.env.DBIS_BOOTSTRAP_EMPLOYEE_PASSWORD?.trim() ||
process.env.DBIS_PORTAL_SHARED_SECRET?.trim()
);
}
function getBootstrapMember() {
return {
memberId: process.env.DBIS_BOOTSTRAP_MEMBER_ID || 'member.test',
memberName: process.env.DBIS_BOOTSTRAP_MEMBER_NAME || 'DBIS Member Test User',
email: process.env.DBIS_BOOTSTRAP_MEMBER_EMAIL || 'member.test@members.d-bis.org',
sovereignBankId: process.env.DBIS_BOOTSTRAP_MEMBER_SOVEREIGN_BANK_ID || null,
};
}
function shouldBootstrapMember() {
return Boolean(
process.env.DBIS_BOOTSTRAP_MEMBER_PASSWORD_HASH?.trim() ||
process.env.DBIS_BOOTSTRAP_MEMBER_PASSWORD?.trim() ||
process.env.DBIS_MEMBER_PORTAL_SHARED_SECRET?.trim()
);
}
function getBootstrapPasswordHash() {
const explicitHash = process.env.DBIS_BOOTSTRAP_EMPLOYEE_PASSWORD_HASH?.trim();
if (explicitHash) {
return explicitHash;
}
const explicitPassword = process.env.DBIS_BOOTSTRAP_EMPLOYEE_PASSWORD?.trim();
if (explicitPassword) {
return bcrypt.hashSync(explicitPassword, 12);
}
const legacyBootstrapSecret = process.env.DBIS_PORTAL_SHARED_SECRET?.trim();
if (legacyBootstrapSecret) {
return bcrypt.hashSync(legacyBootstrapSecret, 12);
}
throw new Error(
'Set DBIS_BOOTSTRAP_EMPLOYEE_PASSWORD_HASH or DBIS_BOOTSTRAP_EMPLOYEE_PASSWORD before bootstrapping portal auth'
);
}
function getBootstrapMemberPasswordHash() {
const explicitHash = process.env.DBIS_BOOTSTRAP_MEMBER_PASSWORD_HASH?.trim();
if (explicitHash) {
return explicitHash;
}
const explicitPassword = process.env.DBIS_BOOTSTRAP_MEMBER_PASSWORD?.trim();
if (explicitPassword) {
return bcrypt.hashSync(explicitPassword, 12);
}
const legacyMemberSecret = process.env.DBIS_MEMBER_PORTAL_SHARED_SECRET?.trim();
if (legacyMemberSecret) {
return bcrypt.hashSync(legacyMemberSecret, 12);
}
throw new Error(
'Set DBIS_BOOTSTRAP_MEMBER_PASSWORD_HASH or DBIS_BOOTSTRAP_MEMBER_PASSWORD before bootstrapping member portal auth'
);
}
async function ensureSchema() {
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS dbis_roles (
id TEXT PRIMARY KEY,
"roleId" TEXT UNIQUE NOT NULL,
"roleName" TEXT NOT NULL,
"roleDescription" TEXT NOT NULL,
"accessLevel" TEXT NOT NULL,
permissions JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"updatedAt" TIMESTAMPTZ NOT NULL
)
`);
await prisma.$executeRawUnsafe(
`CREATE INDEX IF NOT EXISTS dbis_roles_access_level_idx ON dbis_roles ("accessLevel")`
);
await prisma.$executeRawUnsafe(
`CREATE INDEX IF NOT EXISTS dbis_roles_role_id_idx ON dbis_roles ("roleId")`
);
await prisma.$executeRawUnsafe(
`CREATE INDEX IF NOT EXISTS dbis_roles_role_name_idx ON dbis_roles ("roleName")`
);
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS employee_credentials (
id TEXT PRIMARY KEY,
"employeeId" TEXT UNIQUE NOT NULL,
"roleId" TEXT NOT NULL,
"employeeName" TEXT NOT NULL,
email TEXT NOT NULL,
"securityClearance" TEXT NOT NULL,
"portalPasswordHash" TEXT,
"cryptographicBadgeId" TEXT,
"hsmCredentialId" TEXT,
status TEXT NOT NULL DEFAULT 'active',
"issuedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"expiresAt" TIMESTAMPTZ,
"revokedAt" TIMESTAMPTZ,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"updatedAt" TIMESTAMPTZ NOT NULL,
CONSTRAINT employee_credentials_role_fk
FOREIGN KEY ("roleId") REFERENCES dbis_roles(id) ON DELETE CASCADE
)
`);
await prisma.$executeRawUnsafe(
`CREATE INDEX IF NOT EXISTS employee_credentials_employee_id_idx ON employee_credentials ("employeeId")`
);
await prisma.$executeRawUnsafe(
`ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "portalPasswordHash" TEXT`
);
await prisma.$executeRawUnsafe(
`ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "mustRotatePassword" BOOLEAN NOT NULL DEFAULT FALSE`
);
await prisma.$executeRawUnsafe(
`ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "failedLoginAttempts" INTEGER NOT NULL DEFAULT 0`
);
await prisma.$executeRawUnsafe(
`ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "lockedUntil" TIMESTAMPTZ`
);
await prisma.$executeRawUnsafe(
`ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "lastLoginAt" TIMESTAMPTZ`
);
await prisma.$executeRawUnsafe(
`ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "passwordChangedAt" TIMESTAMPTZ`
);
await prisma.$executeRawUnsafe(
`ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "passwordResetTokenHash" TEXT`
);
await prisma.$executeRawUnsafe(
`ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "passwordResetTokenExpiresAt" TIMESTAMPTZ`
);
await prisma.$executeRawUnsafe(
`ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "mfaEnabled" BOOLEAN NOT NULL DEFAULT FALSE`
);
await prisma.$executeRawUnsafe(
`ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "mfaSecretCiphertext" TEXT`
);
await prisma.$executeRawUnsafe(
`ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "mfaSecretIv" TEXT`
);
await prisma.$executeRawUnsafe(
`ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "mfaSecretTag" TEXT`
);
await prisma.$executeRawUnsafe(
`ALTER TABLE employee_credentials ADD COLUMN IF NOT EXISTS "mfaEnrolledAt" TIMESTAMPTZ`
);
await prisma.$executeRawUnsafe(
`CREATE INDEX IF NOT EXISTS employee_credentials_role_id_idx ON employee_credentials ("roleId")`
);
await prisma.$executeRawUnsafe(
`CREATE INDEX IF NOT EXISTS employee_credentials_security_clearance_idx ON employee_credentials ("securityClearance")`
);
await prisma.$executeRawUnsafe(
`CREATE INDEX IF NOT EXISTS employee_credentials_status_idx ON employee_credentials (status)`
);
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS portal_member_accounts (
id TEXT PRIMARY KEY,
"memberId" TEXT UNIQUE NOT NULL,
"memberName" TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
"institutionName" TEXT,
"institutionCountry" TEXT,
"participantId" TEXT,
lei TEXT,
"sovereignBankId" TEXT,
"portalPasswordHash" TEXT NOT NULL,
"approvalStatus" TEXT NOT NULL DEFAULT 'pending',
"approvedAt" TIMESTAMPTZ,
"approvedByEmployeeId" TEXT,
"mustRotatePassword" BOOLEAN NOT NULL DEFAULT FALSE,
"failedLoginAttempts" INTEGER NOT NULL DEFAULT 0,
"lockedUntil" TIMESTAMPTZ,
"lastLoginAt" TIMESTAMPTZ,
"passwordChangedAt" TIMESTAMPTZ,
"passwordResetTokenHash" TEXT,
"passwordResetTokenExpiresAt" TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'active',
"issuedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"expiresAt" TIMESTAMPTZ,
"revokedAt" TIMESTAMPTZ,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"updatedAt" TIMESTAMPTZ NOT NULL
)
`);
await prisma.$executeRawUnsafe(
`ALTER TABLE portal_member_accounts ADD COLUMN IF NOT EXISTS "institutionName" TEXT`
);
await prisma.$executeRawUnsafe(
`ALTER TABLE portal_member_accounts ADD COLUMN IF NOT EXISTS "institutionCountry" TEXT`
);
await prisma.$executeRawUnsafe(
`CREATE INDEX IF NOT EXISTS portal_member_accounts_member_id_idx ON portal_member_accounts ("memberId")`
);
await prisma.$executeRawUnsafe(
`CREATE INDEX IF NOT EXISTS portal_member_accounts_email_idx ON portal_member_accounts (email)`
);
await prisma.$executeRawUnsafe(
`CREATE INDEX IF NOT EXISTS portal_member_accounts_institution_country_idx ON portal_member_accounts ("institutionCountry")`
);
await prisma.$executeRawUnsafe(
`CREATE INDEX IF NOT EXISTS portal_member_accounts_participant_id_idx ON portal_member_accounts ("participantId")`
);
await prisma.$executeRawUnsafe(
`CREATE INDEX IF NOT EXISTS portal_member_accounts_lei_idx ON portal_member_accounts (lei)`
);
await prisma.$executeRawUnsafe(
`CREATE INDEX IF NOT EXISTS portal_member_accounts_approval_status_idx ON portal_member_accounts ("approvalStatus")`
);
await prisma.$executeRawUnsafe(
`CREATE INDEX IF NOT EXISTS portal_member_accounts_status_idx ON portal_member_accounts (status)`
);
await prisma.$executeRawUnsafe(
`CREATE INDEX IF NOT EXISTS portal_member_accounts_sovereign_bank_id_idx ON portal_member_accounts ("sovereignBankId")`
);
}
async function upsertRoles() {
for (const role of DEFAULT_ROLES) {
const existing = await prisma.dbis_roles.findFirst({
where: { roleName: role.roleName },
});
if (existing) {
await prisma.dbis_roles.update({
where: { id: existing.id },
data: {
roleDescription: role.roleDescription,
accessLevel: role.accessLevel,
permissions: role.permissions,
status: 'active',
updatedAt: new Date(),
},
});
continue;
}
await prisma.dbis_roles.create({
data: {
id: uuidv4(),
roleId: uuidv4(),
roleName: role.roleName,
roleDescription: role.roleDescription,
accessLevel: role.accessLevel,
permissions: role.permissions,
status: 'active',
updatedAt: new Date(),
},
});
}
}
async function upsertBootstrapEmployee() {
const employee = getBootstrapEmployee();
const portalPasswordHash = getBootstrapPasswordHash();
const role = await prisma.dbis_roles.findFirst({
where: { roleName: employee.roleName },
});
if (!role) {
throw new Error(`Role ${employee.roleName} was not created`);
}
await prisma.employee_credentials.upsert({
where: { employeeId: employee.employeeId },
update: {
roleId: role.id,
employeeName: employee.employeeName,
email: employee.email,
securityClearance: employee.securityClearance,
portalPasswordHash,
status: 'active',
revokedAt: null,
updatedAt: new Date(),
},
create: {
id: uuidv4(),
employeeId: employee.employeeId,
roleId: role.id,
employeeName: employee.employeeName,
email: employee.email,
securityClearance: employee.securityClearance,
portalPasswordHash,
status: 'active',
updatedAt: new Date(),
},
});
return employee;
}
async function upsertBootstrapMember() {
const member = getBootstrapMember();
const portalPasswordHash = getBootstrapMemberPasswordHash();
const existing = await prisma.portal_member_accounts.findFirst({
where: {
OR: [{ memberId: member.memberId }, { email: member.email }],
},
});
if (existing) {
await prisma.portal_member_accounts.update({
where: { id: existing.id },
data: {
memberId: member.memberId,
memberName: member.memberName,
email: member.email,
sovereignBankId: member.sovereignBankId,
portalPasswordHash,
status: 'active',
revokedAt: null,
updatedAt: new Date(),
},
});
return member;
}
await prisma.portal_member_accounts.create({
data: {
id: uuidv4(),
memberId: member.memberId,
memberName: member.memberName,
email: member.email,
sovereignBankId: member.sovereignBankId,
portalPasswordHash,
status: 'active',
updatedAt: new Date(),
},
});
return member;
}
async function main() {
await ensureSchema();
await upsertRoles();
const employee = shouldBootstrapEmployee() ? await upsertBootstrapEmployee() : null;
const member = shouldBootstrapMember() ? await upsertBootstrapMember() : null;
console.log(
JSON.stringify(
{
ok: true,
bootstrapEmployee: employee
? {
employeeId: employee.employeeId,
email: employee.email,
employeeName: employee.employeeName,
roleName: employee.roleName,
}
: null,
bootstrapMember: member
? {
memberId: member.memberId,
email: member.email,
memberName: member.memberName,
}
: null,
},
null,
2
)
);
}
main()
.catch(async (error) => {
console.error(error.stack || error.message || String(error));
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -145,7 +145,7 @@ create_frontend_container() {
# Create environment file for frontend # Create environment file for frontend
log_info "Creating frontend environment configuration..." log_info "Creating frontend environment configuration..."
local api_url="http://${DBIS_API_PRIMARY_IP:-192.168.11.150}:${DBIS_API_PORT:-3000}" local api_url="http://${DBIS_API_PRIMARY_IP:-192.168.11.155}:${DBIS_API_PORT:-3000}"
pct exec "$vmid" -- bash -c "cat > ${DBIS_CORE_PROJECT_ROOT:-/opt/dbis-core}/frontend/.env <<EOF pct exec "$vmid" -- bash -c "cat > ${DBIS_CORE_PROJECT_ROOT:-/opt/dbis-core}/frontend/.env <<EOF
VITE_API_BASE_URL=${api_url} VITE_API_BASE_URL=${api_url}
@@ -245,4 +245,3 @@ log_info "Next steps:"
log_info "1. Check service status: ./scripts/management/status.sh" log_info "1. Check service status: ./scripts/management/status.sh"
log_info "2. Run database migrations: ./scripts/deployment/configure-database.sh" log_info "2. Run database migrations: ./scripts/deployment/configure-database.sh"
log_info "3. Test API health: curl http://${DBIS_API_PRIMARY_IP:-192.168.11.150}:${DBIS_API_PORT:-3000}/health" log_info "3. Test API health: curl http://${DBIS_API_PRIMARY_IP:-192.168.11.150}:${DBIS_API_PORT:-3000}/health"

View File

@@ -2,40 +2,65 @@
import request from 'supertest'; import request from 'supertest';
import app from '@/integration/api-gateway/app'; import app from '@/integration/api-gateway/app';
import { createAuthHeaders, createTestToken } from '@/__tests__/utils/test-auth'; import { createTestToken } from '@/__tests__/utils/test-auth';
describe('Authentication Middleware', () => { describe('Authentication Middleware', () => {
describe('zeroTrustAuthMiddleware', () => { describe('zeroTrustAuthMiddleware', () => {
it('should reject requests without token', async () => { it('rejects requests without a token', async () => {
const response = await request(app).get('/api/health').expect(401); const response = await request(app).get('/api/auth/me').expect(401);
expect(response.body.success).toBe(false); expect(response.body.success).toBe(false);
expect(response.body.error.code).toBe('UNAUTHORIZED'); expect(response.body.error.code).toBe('UNAUTHORIZED');
}); });
it('should reject requests with invalid token', async () => { it('rejects requests with an invalid token', async () => {
const response = await request(app) const response = await request(app)
.get('/api/health') .get('/api/auth/me')
.set('authorization', 'SOV-TOKEN invalid-token') .set('authorization', 'SOV-TOKEN invalid-token')
.expect(401); .expect(401);
expect(response.body.success).toBe(false); expect(response.body.success).toBe(false);
expect(response.body.error.code).toBe('UNAUTHORIZED');
}); });
it('should accept requests with valid token', async () => { it('accepts a valid portal token without sovereign signature headers', async () => {
// Note: This test may need adjustment based on actual route implementation const token = createTestToken({
// Health endpoint should not require auth employeeId: 'EMP-001',
const response = await request(app).get('/health').expect(200); email: 'core.ops@d-bis.org',
name: 'Core Operations User',
roleName: 'DBIS_Core_User',
permissions: ['portal:access', 'admin:view'],
identityType: 'WEB_PORTAL',
sessionType: 'portal',
portalSurface: 'core',
sovereignBankId: undefined,
});
expect(response.body.status).toBe('healthy'); const response = await request(app)
.get('/api/auth/me')
.set('authorization', `SOV-TOKEN ${token}`)
.expect(200);
expect(response.body.user).toMatchObject({
employeeId: 'EMP-001',
email: 'core.ops@d-bis.org',
name: 'Core Operations User',
role: 'DBIS_Core_User',
permissions: ['portal:access', 'admin:view'],
});
}); });
}); });
describe('Request Signature Verification', () => { describe('Request Signature Verification', () => {
it('should require signature headers', async () => { it('requires signature headers for service tokens', async () => {
const token = createTestToken({ sovereignBankId: 'test-bank' }); const token = createTestToken({
sovereignBankId: 'test-bank',
identityType: 'API',
sessionType: 'service',
});
const response = await request(app) const response = await request(app)
.get('/api/health') .get('/api/auth/me')
.set('authorization', `SOV-TOKEN ${token}`) .set('authorization', `SOV-TOKEN ${token}`)
.expect(401); .expect(401);
@@ -43,4 +68,3 @@ describe('Authentication Middleware', () => {
}); });
}); });
}); });

View File

@@ -0,0 +1,102 @@
import { LeiValidationService } from '@/core/nostro-vostro/lei-validation.service';
import { DbisError } from '@/shared/types';
describe('LeiValidationService', () => {
const validLei = '5493001KJTIIGC8Y1R35';
const originalFetch = global.fetch;
const originalRequireRegistryValidation = process.env.DBIS_REQUIRE_LEI_REGISTRY_VALIDATION;
const originalAllowFormatOnlyFallback = process.env.DBIS_ALLOW_LEI_FORMAT_ONLY_FALLBACK;
beforeEach(() => {
process.env.DBIS_REQUIRE_LEI_REGISTRY_VALIDATION = 'true';
process.env.DBIS_ALLOW_LEI_FORMAT_ONLY_FALLBACK = 'false';
});
afterEach(() => {
global.fetch = originalFetch;
process.env.DBIS_REQUIRE_LEI_REGISTRY_VALIDATION = originalRequireRegistryValidation;
process.env.DBIS_ALLOW_LEI_FORMAT_ONLY_FALLBACK = originalAllowFormatOnlyFallback;
jest.restoreAllMocks();
});
it('rejects malformed LEIs before registry lookup', async () => {
const service = new LeiValidationService();
await expect(
service.validateRegistrationCandidate({
lei: 'INVALID-LEI',
institutionName: 'Central Bank of Example',
country: 'US',
})
).rejects.toBeInstanceOf(DbisError);
});
it('accepts issued active LEIs whose legal name and country match the request', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
data: {
id: validLei,
attributes: {
entity: {
legalName: { name: 'Central Bank of Example' },
legalAddress: { country: 'US' },
status: 'ACTIVE',
},
registration: {
status: 'ISSUED',
initialRegistrationDate: '2020-01-01T00:00:00Z',
nextRenewalDate: '2027-01-01T00:00:00Z',
managingLou: 'TEST-LOU',
},
},
},
}),
}) as typeof fetch;
const service = new LeiValidationService();
const result = await service.validateRegistrationCandidate({
lei: validLei,
institutionName: 'Central Bank of Example',
country: 'US',
});
expect(result.registryValidated).toBe(true);
expect(result.legalName).toBe('Central Bank of Example');
expect(result.legalCountry).toBe('US');
expect(result.source).toBe('gleif');
});
it('rejects registration when the LEI legal name does not match', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
data: {
id: validLei,
attributes: {
entity: {
legalName: { name: 'Different Legal Name Ltd' },
legalAddress: { country: 'US' },
status: 'ACTIVE',
},
registration: {
status: 'ISSUED',
},
},
},
}),
}) as typeof fetch;
const service = new LeiValidationService();
await expect(
service.validateRegistrationCandidate({
lei: validLei,
institutionName: 'Central Bank of Example',
country: 'US',
})
).rejects.toBeInstanceOf(DbisError);
});
});

4
src/bootstrap/env.ts Normal file
View File

@@ -0,0 +1,4 @@
import dotenv from 'dotenv';
// Load process environment before any module performs validation or startup side effects.
dotenv.config();

View File

@@ -78,6 +78,17 @@ export interface GlobalOverviewDashboard {
} }
export class GlobalOverviewService { export class GlobalOverviewService {
private readonly allowPlaceholderMetrics = process.env.DBIS_ENABLE_PLACEHOLDER_METRICS === 'true';
private isMissingTableError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: string }).code === 'P2021'
);
}
/** /**
* Get global overview dashboard * Get global overview dashboard
*/ */
@@ -133,41 +144,25 @@ export class GlobalOverviewService {
subsystems.push({ subsystem: 'GAS', status: 'down' }); subsystems.push({ subsystem: 'GAS', status: 'down' });
} }
// QPS (Quantum Payment System) - placeholder if (this.allowPlaceholderMetrics) {
subsystems.push({ subsystems.push(
subsystem: 'QPS', { subsystem: 'QPS', status: 'healthy' },
status: 'healthy', { subsystem: 'Ω-Layer', status: 'healthy' },
}); { subsystem: 'GPN', status: 'healthy' },
{ subsystem: 'GRU Engine', status: 'healthy' },
// Ω-Layer (Omega Layer) - placeholder { subsystem: 'Metaverse MEN', status: 'healthy' },
subsystems.push({ { subsystem: '6G Edge Grid', status: 'healthy' }
subsystem: 'Ω-Layer', );
status: 'healthy', } else {
}); subsystems.push(
{ subsystem: 'QPS', status: 'degraded' },
// GPN (Global Payment Network) - placeholder { subsystem: 'Ω-Layer', status: 'degraded' },
subsystems.push({ { subsystem: 'GPN', status: 'degraded' },
subsystem: 'GPN', { subsystem: 'GRU Engine', status: 'degraded' },
status: 'healthy', { subsystem: 'Metaverse MEN', status: 'degraded' },
}); { subsystem: '6G Edge Grid', status: 'degraded' }
);
// GRU Engine - placeholder }
subsystems.push({
subsystem: 'GRU Engine',
status: 'healthy',
});
// Metaverse MEN - placeholder
subsystems.push({
subsystem: 'Metaverse MEN',
status: 'healthy',
});
// 6G Edge Grid - placeholder
subsystems.push({
subsystem: '6G Edge Grid',
status: 'healthy',
});
return subsystems; return subsystems;
} }
@@ -176,188 +171,237 @@ export class GlobalOverviewService {
* Get settlement throughput metrics * Get settlement throughput metrics
*/ */
async getSettlementThroughput(): Promise<SettlementThroughput> { async getSettlementThroughput(): Promise<SettlementThroughput> {
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); try {
const oneMinuteAgo = new Date(Date.now() - 60 * 1000); const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const oneMinuteAgo = new Date(Date.now() - 60 * 1000);
// Get all settlements in last 24 hours
const settlements = await prisma.atomic_settlements.findMany({ const settlements = await prisma.atomic_settlements.findMany({
where: { where: {
createdAt: { createdAt: {
gte: oneDayAgo, gte: oneDayAgo,
},
}, },
}, });
});
// Get settlements in last minute for tx/sec const recentSettlements = settlements.filter(
const recentSettlements = settlements.filter( (s: any) => s.createdAt >= oneMinuteAgo
(s: any) => s.createdAt >= oneMinuteAgo );
);
const txPerSecond = recentSettlements.length / 60; const txPerSecond = recentSettlements.length / 60;
const dailyVolume = settlements
// Calculate daily volume
const dailyVolume = settlements
.filter((s: any) => s.status === 'settled') .filter((s: any) => s.status === 'settled')
.reduce((sum: Decimal, s: { amount?: unknown }) => sum.plus(Number(s.amount ?? 0)), new Decimal(0)) .reduce((sum: Decimal, s: { amount?: unknown }) => sum.plus(Number(s.amount ?? 0)), new Decimal(0))
.toNumber(); .toNumber();
// Group by asset type const byAssetType = {
const byAssetType = { fiat: 0,
fiat: 0, cbdc: 0,
cbdc: 0, gru: 0,
gru: 0, ssu: 0,
ssu: 0, commodities: 0,
commodities: 0, };
};
settlements.forEach((s: any) => { settlements.forEach((s: any) => {
if (s.assetType === 'currency') byAssetType.fiat += parseFloat(s.amount.toString()); if (s.assetType === 'currency') byAssetType.fiat += parseFloat(s.amount.toString());
else if (s.assetType === 'cbdc') byAssetType.cbdc += parseFloat(s.amount.toString()); else if (s.assetType === 'cbdc') byAssetType.cbdc += parseFloat(s.amount.toString());
else if (s.assetType === 'commodity') byAssetType.commodities += parseFloat(s.amount.toString()); else if (s.assetType === 'commodity') byAssetType.commodities += parseFloat(s.amount.toString());
// GRU and SSU would need additional queries });
});
// Heatmap: top corridors by volume const corridorMap = new Map<string, number>();
const corridorMap = new Map<string, number>(); settlements.forEach((s: any) => {
settlements.forEach((s: any) => { if (s.status === 'settled') {
if (s.status === 'settled') { const key = `${s.sourceBankId}-${s.destinationBankId}`;
const key = `${s.sourceBankId}-${s.destinationBankId}`; const current = corridorMap.get(key) || 0;
const current = corridorMap.get(key) || 0; corridorMap.set(key, current + parseFloat(s.amount.toString()));
corridorMap.set(key, current + parseFloat(s.amount.toString())); }
} });
});
const heatmap = Array.from(corridorMap.entries()) const heatmap = Array.from(corridorMap.entries())
.map(([key, volume]) => { .map(([key, volume]) => {
const [source, dest] = key.split('-'); const [source, dest] = key.split('-');
return { sourceSCB: source, destinationSCB: dest, volume }; return { sourceSCB: source, destinationSCB: dest, volume };
}) })
.sort((a, b) => b.volume - a.volume) .sort((a, b) => b.volume - a.volume)
.slice(0, 20); // Top 20 .slice(0, 20);
return { return {
txPerSecond, txPerSecond,
dailyVolume, dailyVolume,
byAssetType, byAssetType,
heatmap, heatmap,
}; };
} catch (error) {
logger.warn('Settlement throughput metrics unavailable, returning fallback dashboard values', {
missingTable: this.isMissingTableError(error),
error: error instanceof Error ? error.message : String(error),
});
return {
txPerSecond: 0,
dailyVolume: 0,
byAssetType: {
fiat: 0,
cbdc: 0,
gru: 0,
ssu: 0,
commodities: 0,
},
heatmap: [],
};
}
} }
/** /**
* Get GRU & liquidity metrics * Get GRU & liquidity metrics
*/ */
async getGRULiquidity(): Promise<GRULiquidityMetrics> { async getGRULiquidity(): Promise<GRULiquidityMetrics> {
// Get GRU units try {
const gruUnits = await prisma.gru_units.findMany({ await prisma.gru_units.findMany({
where: { status: 'active' }, where: { status: 'active' },
}); });
// Calculate in circulation by class const inCirculation = {
const inCirculation = { m00: 0,
m00: 0, m0: 0,
m0: 0, m1: 0,
m1: 0, sr1: 0,
sr1: 0, sr2: 0,
sr2: 0, sr3: 0,
sr3: 0, };
};
// Get GRU indexes for price const indexes = await prisma.gru_indexes.findMany({
const indexes = await prisma.gru_indexes.findMany({ where: { status: 'active' },
where: { status: 'active' }, include: { gru_index_price_history: { orderBy: { timestamp: 'desc' }, take: 2 } },
include: { gru_index_price_history: { orderBy: { timestamp: 'desc' }, take: 2 } }, });
});
let currentPrice = 1.0; // Default let currentPrice = 1.0;
let volatility = 0.0; let volatility = 0.0;
if (indexes.length > 0 && (indexes[0] as { gru_index_price_history?: Array<{ indexValue?: unknown }> }).gru_index_price_history?.length >= 2) { const latestIndex = indexes[0] as
const priceHistory = (indexes[0] as { gru_index_price_history: Array<{ indexValue?: unknown }> }).gru_index_price_history; | { gru_index_price_history?: Array<{ indexValue?: unknown }> }
const [latest, previous] = priceHistory; | undefined;
currentPrice = parseFloat(String(latest?.indexValue ?? 1));
const prevPrice = previous ? parseFloat(String(previous.indexValue ?? 1)) : currentPrice; if (latestIndex?.gru_index_price_history && latestIndex.gru_index_price_history.length >= 2) {
volatility = prevPrice > 0 ? Math.abs((currentPrice - prevPrice) / prevPrice) : 0; const priceHistory = latestIndex.gru_index_price_history;
const [latest, previous] = priceHistory;
currentPrice = parseFloat(String(latest?.indexValue ?? 1));
const prevPrice = previous ? parseFloat(String(previous.indexValue ?? 1)) : currentPrice;
volatility = prevPrice > 0 ? Math.abs((currentPrice - prevPrice) / prevPrice) : 0;
}
return {
currentPrice,
volatility,
inCirculation,
};
} catch (error) {
logger.warn('GRU liquidity metrics unavailable, returning fallback dashboard values', {
missingTable: this.isMissingTableError(error),
error: error instanceof Error ? error.message : String(error),
});
return {
currentPrice: 0,
volatility: 0,
inCirculation: {
m00: 0,
m0: 0,
m1: 0,
sr1: 0,
sr2: 0,
sr3: 0,
},
};
} }
return {
currentPrice,
volatility,
inCirculation,
};
} }
/** /**
* Get risk flags and alerts * Get risk flags and alerts
*/ */
async getRiskFlags(): Promise<RiskFlags> { async getRiskFlags(): Promise<RiskFlags> {
const dashboard = await dashboardService.getIncidentAlertsDashboard(); try {
const dashboard = await dashboardService.getIncidentAlertsDashboard();
const alerts = dashboard.incidentAlerts || [];
const high = alerts.filter((a: any) => a.severity === 'critical' || a.severity === 'high').length;
const medium = alerts.filter((a: any) => a.severity === 'medium').length;
const low = alerts.filter((a: any) => a.severity === 'low').length;
const alerts = dashboard.incidentAlerts || []; return {
const high = alerts.filter((a: any) => a.severity === 'critical' || a.severity === 'high').length; high,
const medium = alerts.filter((a: any) => a.severity === 'medium').length; medium,
const low = alerts.filter((a: any) => a.severity === 'low').length; low,
alerts: alerts.slice(0, 10).map((a: any, idx: number) => ({
return { id: a.id || `alert-${idx}`,
high, type: a.type || 'unknown',
medium, severity: a.severity || 'low',
low, description: a.description || '',
alerts: alerts.slice(0, 10).map((a: any, idx: number) => ({ timestamp: a.timestamp || new Date(),
id: a.id || `alert-${idx}`, })),
type: a.type || 'unknown', };
severity: a.severity || 'low', } catch (error) {
description: a.description || '', logger.warn('Risk flags unavailable, returning fallback dashboard values', {
timestamp: a.timestamp || new Date(), missingTable: this.isMissingTableError(error),
})), error: error instanceof Error ? error.message : String(error),
}; });
return {
high: 0,
medium: 0,
low: 0,
alerts: [],
};
}
} }
/** /**
* Get SCB status table * Get SCB status table
*/ */
async getSCBStatus(): Promise<SCBStatus[]> { async getSCBStatus(): Promise<SCBStatus[]> {
const scbs = await prisma.sovereign_banks.findMany({ try {
where: { status: { in: ['active', 'suspended'] } }, const scbs = await prisma.sovereign_banks.findMany({
}); where: { status: { in: ['active', 'suspended'] } },
});
const scbStatus: SCBStatus[] = []; const scbStatus: SCBStatus[] = [];
for (const scb of scbs) { for (const scb of scbs) {
// Get recent settlements to determine connectivity const recentSettlements = await prisma.atomic_settlements.findMany({
const recentSettlements = await prisma.atomic_settlements.findMany({ where: {
where: { OR: [{ sourceBankId: scb.id }, { destinationBankId: scb.id }],
OR: [{ sourceBankId: scb.id }, { destinationBankId: scb.id }], createdAt: {
createdAt: { gte: new Date(Date.now() - 5 * 60 * 1000),
gte: new Date(Date.now() - 5 * 60 * 1000), // Last 5 minutes },
}, },
}, take: 10,
take: 10, });
});
const connectivity = const connectivity =
recentSettlements.length > 0 ? 'connected' : 'degraded'; recentSettlements.length > 0 ? 'connected' : 'degraded';
// Get open incidents (SRI enforcements) const openIncidents = await prisma.sri_enforcements.count({
const openIncidents = await prisma.sri_enforcements.count({ where: {
where: { sovereignBankId: scb.id,
sovereignBankId: scb.id, status: 'active',
status: 'active', },
}, });
});
scbStatus.push({ scbStatus.push({
scbId: scb.id, scbId: scb.id,
name: scb.name, name: scb.name,
country: scb.sovereignCode, country: scb.sovereignCode,
bic: scb.bic || undefined, bic: scb.bic || undefined,
status: scb.status, status: scb.status,
connectivity, connectivity,
openIncidents, openIncidents,
});
}
return scbStatus;
} catch (error) {
logger.warn('SCB status unavailable, returning fallback dashboard values', {
missingTable: this.isMissingTableError(error),
error: error instanceof Error ? error.message : String(error),
}); });
return [];
} }
return scbStatus;
} }
} }
export const globalOverviewService = new GlobalOverviewService(); export const globalOverviewService = new GlobalOverviewService();

View File

@@ -103,7 +103,7 @@ router.post(
requireAdminPermission(AdminPermission.GRU_ISSUANCE_PROPOSAL), requireAdminPermission(AdminPermission.GRU_ISSUANCE_PROPOSAL),
async (req, res, next) => { async (req, res, next) => {
try { try {
const employeeId = req.headers['x-employee-id'] as string || (req as any).sovereignBankId || ''; const employeeId = req.employeeId || '';
const result = await dbisAdminService.gruControls.createIssuanceProposal( const result = await dbisAdminService.gruControls.createIssuanceProposal(
employeeId, employeeId,
req.body req.body
@@ -120,7 +120,7 @@ router.post(
requireAdminPermission(AdminPermission.GRU_LOCK_UNLOCK), requireAdminPermission(AdminPermission.GRU_LOCK_UNLOCK),
async (req, res, next) => { async (req, res, next) => {
try { try {
const employeeId = req.headers['x-employee-id'] as string || (req as any).sovereignBankId || ''; const employeeId = req.employeeId || '';
const result = await dbisAdminService.gruControls.lockUnlockGRUClass(employeeId, req.body); const result = await dbisAdminService.gruControls.lockUnlockGRUClass(employeeId, req.body);
return res.json(result); return res.json(result);
} catch (error) { } catch (error) {
@@ -134,7 +134,7 @@ router.post(
requireAdminPermission(AdminPermission.GRU_CIRCUIT_BREAKERS), requireAdminPermission(AdminPermission.GRU_CIRCUIT_BREAKERS),
async (req, res, next) => { async (req, res, next) => {
try { try {
const employeeId = req.headers['x-employee-id'] as string || (req as any).sovereignBankId || ''; const employeeId = req.employeeId || '';
const result = await dbisAdminService.gruControls.setCircuitBreakers(employeeId, req.body); const result = await dbisAdminService.gruControls.setCircuitBreakers(employeeId, req.body);
return res.json(result); return res.json(result);
} catch (error) { } catch (error) {
@@ -148,7 +148,7 @@ router.post(
requireAdminPermission(AdminPermission.GRU_BOND_ISSUANCE_WINDOW), requireAdminPermission(AdminPermission.GRU_BOND_ISSUANCE_WINDOW),
async (req, res, next) => { async (req, res, next) => {
try { try {
const employeeId = req.headers['x-employee-id'] as string || (req as any).sovereignBankId || ''; const employeeId = req.employeeId || '';
const result = await dbisAdminService.gruControls.manageBondIssuanceWindow( const result = await dbisAdminService.gruControls.manageBondIssuanceWindow(
employeeId, employeeId,
req.body req.body
@@ -165,7 +165,7 @@ router.post(
requireAdminPermission(AdminPermission.GRU_BOND_BUYBACK), requireAdminPermission(AdminPermission.GRU_BOND_BUYBACK),
async (req, res, next) => { async (req, res, next) => {
try { try {
const employeeId = req.headers['x-employee-id'] as string || (req as any).sovereignBankId || ''; const employeeId = req.employeeId || '';
const { bondId, amount } = req.body; const { bondId, amount } = req.body;
const result = await dbisAdminService.gruControls.triggerEmergencyBuyback( const result = await dbisAdminService.gruControls.triggerEmergencyBuyback(
employeeId, employeeId,
@@ -241,7 +241,7 @@ router.post(
requireAdminPermission(AdminPermission.CORRIDOR_ADJUST_CAPS), requireAdminPermission(AdminPermission.CORRIDOR_ADJUST_CAPS),
async (req, res, next) => { async (req, res, next) => {
try { try {
const employeeId = req.headers['x-employee-id'] as string || (req as any).sovereignBankId || ''; const employeeId = req.employeeId || '';
const result = await dbisAdminService.corridorControls.adjustCorridorCaps( const result = await dbisAdminService.corridorControls.adjustCorridorCaps(
employeeId, employeeId,
req.body req.body
@@ -258,7 +258,7 @@ router.post(
requireAdminPermission(AdminPermission.CORRIDOR_THROTTLE), requireAdminPermission(AdminPermission.CORRIDOR_THROTTLE),
async (req, res, next) => { async (req, res, next) => {
try { try {
const employeeId = req.headers['x-employee-id'] as string || (req as any).sovereignBankId || ''; const employeeId = req.employeeId || '';
const result = await dbisAdminService.corridorControls.throttleCorridor( const result = await dbisAdminService.corridorControls.throttleCorridor(
employeeId, employeeId,
req.body req.body
@@ -275,7 +275,7 @@ router.post(
requireAdminPermission(AdminPermission.CORRIDOR_ENABLE_DISABLE), requireAdminPermission(AdminPermission.CORRIDOR_ENABLE_DISABLE),
async (req, res, next) => { async (req, res, next) => {
try { try {
const employeeId = req.headers['x-employee-id'] as string || (req as any).sovereignBankId || ''; const employeeId = req.employeeId || '';
const result = await dbisAdminService.corridorControls.enableDisableCorridor( const result = await dbisAdminService.corridorControls.enableDisableCorridor(
employeeId, employeeId,
req.body req.body
@@ -293,7 +293,7 @@ router.post(
requireAdminPermission(AdminPermission.NETWORK_QUIESCE_SUBSYSTEM), requireAdminPermission(AdminPermission.NETWORK_QUIESCE_SUBSYSTEM),
async (req, res, next) => { async (req, res, next) => {
try { try {
const employeeId = req.headers['x-employee-id'] as string || (req as any).sovereignBankId || ''; const employeeId = req.employeeId || '';
const result = await dbisAdminService.networkControls.quiesceSubsystem(employeeId, req.body); const result = await dbisAdminService.networkControls.quiesceSubsystem(employeeId, req.body);
return res.json(result); return res.json(result);
} catch (error) { } catch (error) {
@@ -307,7 +307,7 @@ router.post(
requireAdminPermission(AdminPermission.NETWORK_KILL_SWITCH), requireAdminPermission(AdminPermission.NETWORK_KILL_SWITCH),
async (req, res, next) => { async (req, res, next) => {
try { try {
const employeeId = req.headers['x-employee-id'] as string || (req as any).sovereignBankId || ''; const employeeId = req.employeeId || '';
const result = await dbisAdminService.networkControls.activateKillSwitch( const result = await dbisAdminService.networkControls.activateKillSwitch(
employeeId, employeeId,
req.body req.body
@@ -324,7 +324,7 @@ router.post(
requireAdminPermission(AdminPermission.NETWORK_ESCALATE_INCIDENT), requireAdminPermission(AdminPermission.NETWORK_ESCALATE_INCIDENT),
async (req, res, next) => { async (req, res, next) => {
try { try {
const employeeId = req.headers['x-employee-id'] as string || (req as any).sovereignBankId || ''; const employeeId = req.employeeId || '';
const result = await dbisAdminService.networkControls.escalateIncident( const result = await dbisAdminService.networkControls.escalateIncident(
employeeId, employeeId,
req.body req.body
@@ -337,4 +337,3 @@ router.post(
); );
export default router; export default router;

View File

@@ -88,6 +88,8 @@ export interface SCBOverviewDashboard {
} }
export class SCBOverviewService { export class SCBOverviewService {
private readonly allowPlaceholderMetrics = process.env.DBIS_ENABLE_PLACEHOLDER_METRICS === 'true';
/** /**
* Get SCB overview dashboard * Get SCB overview dashboard
*/ */
@@ -121,22 +123,23 @@ export class SCBOverviewService {
new Decimal(0) new Decimal(0)
).toNumber(); ).toNumber();
// Get payment rails (placeholder) const paymentRails = this.allowPlaceholderMetrics
const paymentRails = [ ? [
{ {
railType: 'RTGS', railType: 'RTGS',
status: 'active' as const, status: 'active' as const,
volume24h: 0, volume24h: 0,
}, },
{ {
railType: 'CBDC', railType: 'CBDC',
status: 'active' as const, status: 'active' as const,
volume24h: cbdcInCirculation, volume24h: cbdcInCirculation,
}, },
]; ]
: [];
return { return {
fiCount: 0, // Would query FI table fiCount: 0,
activeFIs: 0, activeFIs: 0,
paymentRails, paymentRails,
cbdcStatus: { cbdcStatus: {
@@ -148,7 +151,7 @@ export class SCBOverviewService {
}, },
}, },
nostroVostroStatus: { nostroVostroStatus: {
totalAccounts: 0, // Would query Nostro/Vostro accounts totalAccounts: 0,
activeAccounts: 0, activeAccounts: 0,
apiEnabled: true, apiEnabled: true,
}, },
@@ -286,4 +289,3 @@ export class SCBOverviewService {
} }
export const scbOverviewService = new SCBOverviewService(); export const scbOverviewService = new SCBOverviewService();

View File

@@ -52,7 +52,7 @@ router.post(
if (!scbId) { if (!scbId) {
return res.status(400).json({ error: 'Sovereign Bank ID required' }); return res.status(400).json({ error: 'Sovereign Bank ID required' });
} }
const employeeId = req.headers['x-employee-id'] as string || scbId; const employeeId = req.employeeId || scbId;
const result = await scbAdminService.fiControls.approveSuspendFI( const result = await scbAdminService.fiControls.approveSuspendFI(
employeeId, employeeId,
scbId, scbId,
@@ -74,7 +74,7 @@ router.post(
if (!scbId) { if (!scbId) {
return res.status(400).json({ error: 'Sovereign Bank ID required' }); return res.status(400).json({ error: 'Sovereign Bank ID required' });
} }
const employeeId = req.headers['x-employee-id'] as string || scbId; const employeeId = req.employeeId || scbId;
const result = await scbAdminService.fiControls.setFILimits(employeeId, scbId, req.body); const result = await scbAdminService.fiControls.setFILimits(employeeId, scbId, req.body);
return res.json(result); return res.json(result);
} catch (error) { } catch (error) {
@@ -92,7 +92,7 @@ router.post(
if (!scbId) { if (!scbId) {
return res.status(400).json({ error: 'Sovereign Bank ID required' }); return res.status(400).json({ error: 'Sovereign Bank ID required' });
} }
const employeeId = req.headers['x-employee-id'] as string || scbId; const employeeId = req.employeeId || scbId;
const result = await scbAdminService.fiControls.assignAPIProfile( const result = await scbAdminService.fiControls.assignAPIProfile(
employeeId, employeeId,
scbId, scbId,
@@ -133,7 +133,7 @@ router.post(
if (!scbId) { if (!scbId) {
return res.status(400).json({ error: 'Sovereign Bank ID required' }); return res.status(400).json({ error: 'Sovereign Bank ID required' });
} }
const employeeId = req.headers['x-employee-id'] as string || scbId; const employeeId = req.employeeId || scbId;
const result = await scbAdminService.cbdcControls.updateCBDCParameters( const result = await scbAdminService.cbdcControls.updateCBDCParameters(
employeeId, employeeId,
scbId, scbId,
@@ -155,7 +155,7 @@ router.post(
if (!scbId) { if (!scbId) {
return res.status(400).json({ error: 'Sovereign Bank ID required' }); return res.status(400).json({ error: 'Sovereign Bank ID required' });
} }
const employeeId = req.headers['x-employee-id'] as string || scbId; const employeeId = req.employeeId || scbId;
const result = await scbAdminService.cbdcControls.updateGRUPolicy( const result = await scbAdminService.cbdcControls.updateGRUPolicy(
employeeId, employeeId,
scbId, scbId,
@@ -169,4 +169,3 @@ router.post(
); );
export default router; export default router;

View File

@@ -0,0 +1,259 @@
import { DbisError, ErrorCode } from '@/shared/types';
import { logger } from '@/infrastructure/monitoring/logger';
export interface LeiRegistryRecord {
lei: string;
legalName: string | null;
legalCountry: string | null;
registrationStatus: string | null;
entityStatus: string | null;
initialRegistrationDate: string | null;
nextRenewalDate: string | null;
managingLou: string | null;
}
export interface LeiValidationResult {
normalizedLei: string;
registryValidated: boolean;
legalName: string | null;
legalCountry: string | null;
registrationStatus: string | null;
entityStatus: string | null;
initialRegistrationDate: string | null;
nextRenewalDate: string | null;
managingLou: string | null;
validatedAt: string;
source: 'gleif' | 'format-only';
}
interface RegistrationValidationRequest {
lei?: string;
institutionName: string;
country: string;
}
function normalizeLei(input: string): string {
return input.trim().toUpperCase().replace(/\s+/g, '');
}
function normalizeCountry(input: string): string {
return input.trim().toUpperCase();
}
function normalizeEntityName(input: string): string {
return input
.trim()
.toUpperCase()
.replace(/&/g, ' AND ')
.replace(/[^A-Z0-9]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function alphaNumericToIso7064Digits(input: string): string {
return input
.split('')
.map((char) => {
if (/[0-9]/.test(char)) {
return char;
}
const code = char.charCodeAt(0);
if (code >= 65 && code <= 90) {
return String(code - 55);
}
throw new DbisError(ErrorCode.VALIDATION_ERROR, 'LEI contains invalid characters');
})
.join('');
}
function iso7064Mod97(value: string): number {
let remainder = 0;
for (const char of value) {
remainder = (remainder * 10 + Number(char)) % 97;
}
return remainder;
}
function isValidLeiChecksum(lei: string): boolean {
const rearranged = `${lei.slice(4)}${lei.slice(0, 4)}`;
const numeric = alphaNumericToIso7064Digits(rearranged);
return iso7064Mod97(numeric) === 1;
}
export class LeiValidationService {
private readonly apiBaseUrl = (process.env.GLEIF_API_BASE_URL || 'https://api.gleif.org/api/v1').replace(/\/$/, '');
private readonly timeoutMs = Number(process.env.DBIS_LEI_LOOKUP_TIMEOUT_MS || '5000');
private readonly requireRegistryValidation = process.env.DBIS_REQUIRE_LEI_REGISTRY_VALIDATION !== 'false';
private readonly allowFormatOnlyFallback = process.env.DBIS_ALLOW_LEI_FORMAT_ONLY_FALLBACK === 'true';
validateLeiFormat(rawLei?: string): string {
if (!rawLei?.trim()) {
throw new DbisError(
ErrorCode.VALIDATION_ERROR,
'A Legal Entity Identifier (LEI) is required for new international financial entity registration'
);
}
const lei = normalizeLei(rawLei);
if (!/^[A-Z0-9]{20}$/.test(lei)) {
throw new DbisError(ErrorCode.VALIDATION_ERROR, 'LEI must be exactly 20 alphanumeric characters');
}
return lei;
}
async validateRegistrationCandidate(
request: RegistrationValidationRequest
): Promise<LeiValidationResult> {
const normalizedLei = this.validateLeiFormat(request.lei);
const requestedName = normalizeEntityName(request.institutionName);
const requestedCountry = normalizeCountry(request.country);
const registryRecord = await this.lookupLeiRecord(normalizedLei);
if (!registryRecord) {
if (this.requireRegistryValidation && !this.allowFormatOnlyFallback) {
throw new DbisError(
ErrorCode.VALIDATION_ERROR,
`LEI ${normalizedLei} could not be validated against the registry`
);
}
return {
normalizedLei,
registryValidated: false,
legalName: null,
legalCountry: null,
registrationStatus: null,
entityStatus: null,
initialRegistrationDate: null,
nextRenewalDate: null,
managingLou: null,
validatedAt: new Date().toISOString(),
source: 'format-only',
};
}
if (registryRecord.registrationStatus && registryRecord.registrationStatus !== 'ISSUED') {
throw new DbisError(
ErrorCode.VALIDATION_ERROR,
`LEI ${normalizedLei} is not currently issued`,
{ registrationStatus: registryRecord.registrationStatus }
);
}
if (registryRecord.entityStatus && registryRecord.entityStatus !== 'ACTIVE') {
throw new DbisError(
ErrorCode.VALIDATION_ERROR,
`LEI ${normalizedLei} is not associated with an active legal entity`,
{ entityStatus: registryRecord.entityStatus }
);
}
if (registryRecord.legalName) {
const normalizedLegalName = normalizeEntityName(registryRecord.legalName);
if (normalizedLegalName !== requestedName) {
throw new DbisError(
ErrorCode.VALIDATION_ERROR,
'Registered party name does not match the LEI legal name',
{
providedName: request.institutionName,
leiLegalName: registryRecord.legalName,
}
);
}
}
if (registryRecord.legalCountry && normalizeCountry(registryRecord.legalCountry) !== requestedCountry) {
throw new DbisError(
ErrorCode.VALIDATION_ERROR,
'Registered party country does not match the LEI legal address country',
{
providedCountry: requestedCountry,
leiCountry: registryRecord.legalCountry,
}
);
}
return {
normalizedLei,
registryValidated: true,
legalName: registryRecord.legalName,
legalCountry: registryRecord.legalCountry,
registrationStatus: registryRecord.registrationStatus,
entityStatus: registryRecord.entityStatus,
initialRegistrationDate: registryRecord.initialRegistrationDate,
nextRenewalDate: registryRecord.nextRenewalDate,
managingLou: registryRecord.managingLou,
validatedAt: new Date().toISOString(),
source: 'gleif',
};
}
private async lookupLeiRecord(lei: string): Promise<LeiRegistryRecord | null> {
const url = `${this.apiBaseUrl}/lei-records/${encodeURIComponent(lei)}`;
try {
const response = await fetch(url, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(this.timeoutMs),
});
if (response.status === 404) {
return null;
}
if (!response.ok) {
logger.warn('LEI registry lookup returned non-success status', {
lei,
status: response.status,
statusText: response.statusText,
});
return null;
}
const payload = (await response.json()) as {
data?: {
id?: string;
attributes?: {
entity?: {
legalName?: { name?: string | null };
legalAddress?: { country?: string | null };
status?: string | null;
};
registration?: {
status?: string | null;
initialRegistrationDate?: string | null;
nextRenewalDate?: string | null;
managingLou?: string | null;
};
};
};
};
const attributes = payload.data?.attributes;
return {
lei: payload.data?.id || lei,
legalName: attributes?.entity?.legalName?.name || null,
legalCountry: attributes?.entity?.legalAddress?.country || null,
registrationStatus: attributes?.registration?.status || null,
entityStatus: attributes?.entity?.status || null,
initialRegistrationDate: attributes?.registration?.initialRegistrationDate || null,
nextRenewalDate: attributes?.registration?.nextRenewalDate || null,
managingLou: attributes?.registration?.managingLou || null,
};
} catch (error) {
logger.warn('LEI registry lookup failed', {
lei,
url,
error: error instanceof Error ? error.message : 'Unknown error',
});
return null;
}
}
}
export const leiValidationService = new LeiValidationService();

View File

@@ -7,6 +7,10 @@
import { Router } from 'express'; import { Router } from 'express';
import { zeroTrustAuthMiddleware } from '@/integration/api-gateway/middleware/auth.middleware'; import { zeroTrustAuthMiddleware } from '@/integration/api-gateway/middleware/auth.middleware';
import {
validateRequest,
nostroVostroValidationSchemas,
} from '@/integration/api-gateway/middleware/validation.middleware';
import { nostroVostroService } from './nostro-vostro.service'; import { nostroVostroService } from './nostro-vostro.service';
import { reconciliationService } from './reconciliation.service'; import { reconciliationService } from './reconciliation.service';
import { webhookService } from './webhook.service'; import { webhookService } from './webhook.service';
@@ -177,7 +181,7 @@ router.get('/participants/:participantId', zeroTrustAuthMiddleware, async (req,
* example: * example:
* name: "Central Bank of Example" * name: "Central Bank of Example"
* bic: "CBEXUS33" * bic: "CBEXUS33"
* lei: "5493000X9ZXSQ9B6Y815" * lei: "5493001KJTIIGC8Y1R35"
* country: "US" * country: "US"
* regulatoryTier: "SCB" * regulatoryTier: "SCB"
* responses: * responses:
@@ -197,19 +201,24 @@ router.get('/participants/:participantId', zeroTrustAuthMiddleware, async (req,
* 401: * 401:
* $ref: '#/components/responses/Unauthorized' * $ref: '#/components/responses/Unauthorized'
*/ */
router.post('/participants', zeroTrustAuthMiddleware, async (req, res, next) => { router.post(
try { '/participants',
const participant = await nostroVostroService.createParticipant(req.body); zeroTrustAuthMiddleware,
validateRequest({ body: nostroVostroValidationSchemas.participantCreateRequest }),
async (req, res, next) => {
try {
const participant = await nostroVostroService.createParticipant(req.body);
res.status(201).json({ res.status(201).json({
success: true, success: true,
data: participant, data: participant,
timestamp: new Date(), timestamp: new Date(),
}); });
} catch (error) { } catch (error) {
return next(error); return next(error);
}
} }
}); );
// ============================================================================ // ============================================================================
// Account Endpoints // Account Endpoints
@@ -858,4 +867,3 @@ router.post('/webhooks/events', async (req, res, next) => {
}); });
export default router; export default router;

View File

@@ -17,6 +17,7 @@ import {
TransferStatus, TransferStatus,
SettlementAsset, SettlementAsset,
} from './nostro-vostro.types'; } from './nostro-vostro.types';
import { leiValidationService } from './lei-validation.service';
export class NostroVostroService { export class NostroVostroService {
// ============================================================================ // ============================================================================
@@ -27,7 +28,15 @@ export class NostroVostroService {
* Create a new participant * Create a new participant
*/ */
async createParticipant(request: ParticipantCreateRequest): Promise<Participant> { async createParticipant(request: ParticipantCreateRequest): Promise<Participant> {
const participantId = request.participantId || `PART-${uuidv4()}`; const participantId = request.participantId?.trim() || `PART-${uuidv4()}`;
const normalizedBic = request.bic?.trim().toUpperCase();
const normalizedCountry = request.country.trim().toUpperCase();
const normalizedLei = leiValidationService.validateLeiFormat(request.lei);
const leiValidation = await leiValidationService.validateRegistrationCandidate({
lei: normalizedLei,
institutionName: request.name,
country: normalizedCountry,
});
// Check if participantId already exists // Check if participantId already exists
const existing = await prisma.nostro_vostro_participants.findUnique({ const existing = await prisma.nostro_vostro_participants.findUnique({
@@ -39,36 +48,40 @@ export class NostroVostroService {
} }
// Check BIC uniqueness if provided // Check BIC uniqueness if provided
if (request.bic) { if (normalizedBic) {
const existingBic = await prisma.nostro_vostro_participants.findUnique({ const existingBic = await prisma.nostro_vostro_participants.findUnique({
where: { bic: request.bic }, where: { bic: normalizedBic },
}); });
if (existingBic) { if (existingBic) {
throw new DbisError(ErrorCode.VALIDATION_ERROR, `Participant with BIC ${request.bic} already exists`); throw new DbisError(ErrorCode.VALIDATION_ERROR, `Participant with BIC ${normalizedBic} already exists`);
} }
} }
// Check LEI uniqueness if provided const existingLei = await prisma.nostro_vostro_participants.findUnique({
if (request.lei) { where: { lei: normalizedLei },
const existingLei = await prisma.nostro_vostro_participants.findUnique({ });
where: { lei: request.lei }, if (existingLei) {
}); throw new DbisError(ErrorCode.VALIDATION_ERROR, `Participant with LEI ${normalizedLei} already exists`);
if (existingLei) {
throw new DbisError(ErrorCode.VALIDATION_ERROR, `Participant with LEI ${request.lei} already exists`);
}
} }
const metadata = {
...(request.metadata || {}),
leiValidation,
} as unknown as Prisma.InputJsonValue;
const participant = await prisma.nostro_vostro_participants.create({ const participant = await prisma.nostro_vostro_participants.create({
data: { data: {
id: uuidv4(),
participantId, participantId,
name: request.name, name: request.name,
bic: request.bic, bic: normalizedBic,
lei: request.lei, lei: normalizedLei,
country: request.country, country: normalizedCountry,
regulatoryTier: request.regulatoryTier, regulatoryTier: request.regulatoryTier,
sovereignBankId: request.sovereignBankId, sovereignBankId: request.sovereignBankId,
status: 'active', status: 'active',
metadata: (request.metadata || {}) as Prisma.InputJsonValue, metadata,
updatedAt: new Date(),
}, },
}); });
@@ -163,6 +176,7 @@ export class NostroVostroService {
const account = await prisma.nostro_vostro_accounts.create({ const account = await prisma.nostro_vostro_accounts.create({
data: { data: {
id: uuidv4(),
accountId, accountId,
ownerParticipantId: request.ownerParticipantId, ownerParticipantId: request.ownerParticipantId,
counterpartyParticipantId: request.counterpartyParticipantId, counterpartyParticipantId: request.counterpartyParticipantId,
@@ -173,7 +187,8 @@ export class NostroVostroService {
currentBalance: new Decimal(0), currentBalance: new Decimal(0),
availableLiquidity: new Decimal(0), availableLiquidity: new Decimal(0),
holdAmount: new Decimal(0), holdAmount: new Decimal(0),
metadata: (request.metadata || {}) as Prisma.InputJsonValue, metadata: (request.metadata || {}) as unknown as Prisma.InputJsonValue,
updatedAt: new Date(),
}, },
}); });
@@ -593,4 +608,3 @@ export class NostroVostroService {
} }
export const nostroVostroService = new NostroVostroService(); export const nostroVostroService = new NostroVostroService();

View File

@@ -51,7 +51,7 @@ export interface ScreenAccess {
} }
export interface ActionPermissions { export interface ActionPermissions {
[module: string]: Action[]; [module: string]: Array<Action | '*'>;
} }
export interface ApprovalRequirement { export interface ApprovalRequirement {
@@ -164,4 +164,3 @@ export interface ResourceContext {
currency?: string; currency?: string;
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
} }

View File

@@ -109,9 +109,9 @@ export class RbacEngineService {
participant: ParticipantType; participant: ParticipantType;
accessLevel: string; accessLevel: string;
} | null> { } | null> {
const employee = await prisma.employeeCredential.findUnique({ const employee = await prisma.employee_credentials.findUnique({
where: { employeeId }, where: { employeeId },
include: { role: true }, include: { dbis_roles: true },
}); });
if (!employee || employee.status !== 'active') { if (!employee || employee.status !== 'active') {
@@ -119,7 +119,7 @@ export class RbacEngineService {
} }
// Determine participant type from role name // Determine participant type from role name
const roleName = employee.role.roleName; const roleName = employee.dbis_roles.roleName;
let participant: ParticipantType = 'DBIS'; let participant: ParticipantType = 'DBIS';
if (roleName.startsWith('SCB_')) { if (roleName.startsWith('SCB_')) {
@@ -131,7 +131,7 @@ export class RbacEngineService {
return { return {
roleName, roleName,
participant, participant,
accessLevel: employee.role.accessLevel, accessLevel: employee.dbis_roles.accessLevel,
}; };
} }
@@ -421,7 +421,8 @@ export class RbacEngineService {
return []; return [];
} }
return roleDef.actions[module] || roleDef.actions['*'] || []; const actions = roleDef.actions[module] || roleDef.actions['*'] || [];
return actions.filter((action): action is Action => action !== '*');
} }
/** /**
@@ -520,4 +521,3 @@ export class RbacEngineService {
} }
export const rbacEngineService = new RbacEngineService(); export const rbacEngineService = new RbacEngineService();

View File

@@ -115,6 +115,7 @@ export class RoleManagementService {
accessLevel: roleData.accessLevel, accessLevel: roleData.accessLevel,
permissions: roleData.permissions, permissions: roleData.permissions,
status: 'active', status: 'active',
updatedAt: new Date(),
}, },
}); });
} }
@@ -134,6 +135,7 @@ export class RoleManagementService {
accessLevel: roleData.accessLevel, accessLevel: roleData.accessLevel,
permissions: roleData.permissions, permissions: roleData.permissions,
status: 'active', status: 'active',
updatedAt: new Date(),
}, },
}); });
} }
@@ -144,7 +146,7 @@ export class RoleManagementService {
async getRole(roleId: string) { async getRole(roleId: string) {
return await prisma.dbis_roles.findUnique({ return await prisma.dbis_roles.findUnique({
where: { roleId }, where: { roleId },
include: { employees: true }, include: { employee_credentials: true },
}); });
} }
@@ -183,14 +185,14 @@ export class RoleManagementService {
async hasPermission(employeeId: string, permission: string): Promise<boolean> { async hasPermission(employeeId: string, permission: string): Promise<boolean> {
const employee = await prisma.employee_credentials.findUnique({ const employee = await prisma.employee_credentials.findUnique({
where: { employeeId }, where: { employeeId },
include: { dbis_roles: true }, include: { dbis_roles: true },
}); });
if (!employee || employee.status !== 'active') { if (!employee || employee.status !== 'active') {
return false; return false;
} }
const permissions = employee.role.permissions as string[]; const permissions = employee.dbis_roles.permissions as string[];
return permissions.includes('all') || permissions.includes(permission); return permissions.includes('all') || permissions.includes(permission);
} }
@@ -200,10 +202,10 @@ export class RoleManagementService {
async getAccessLevel(employeeId: string): Promise<string | null> { async getAccessLevel(employeeId: string): Promise<string | null> {
const employee = await prisma.employee_credentials.findUnique({ const employee = await prisma.employee_credentials.findUnique({
where: { employeeId }, where: { employeeId },
include: { dbis_roles: true }, include: { dbis_roles: true },
}); });
return employee?.role.accessLevel || null; return employee?.dbis_roles.accessLevel || null;
} }
/** /**
@@ -260,14 +262,14 @@ export class RoleManagementService {
} | null> { } | null> {
const employee = await prisma.employee_credentials.findUnique({ const employee = await prisma.employee_credentials.findUnique({
where: { employeeId }, where: { employeeId },
include: { dbis_roles: true }, include: { dbis_roles: true },
}); });
if (!employee || employee.status !== 'active') { if (!employee || employee.status !== 'active') {
return null; return null;
} }
const roleName = employee.role.roleName; const roleName = employee.dbis_roles.roleName;
let participant: ParticipantType = 'DBIS'; let participant: ParticipantType = 'DBIS';
if (roleName.startsWith('SCB_')) { if (roleName.startsWith('SCB_')) {
@@ -284,11 +286,10 @@ export class RoleManagementService {
return { return {
roleName, roleName,
participant, participant,
accessLevel: employee.role.accessLevel, accessLevel: employee.dbis_roles.accessLevel,
description: roleDef.description, description: roleDef.description,
}; };
} }
} }
export const roleManagementService = new RoleManagementService(); export const roleManagementService = new RoleManagementService();

View File

@@ -23,6 +23,16 @@ export interface MessageEncryption {
} }
export class As4SecurityService { export class As4SecurityService {
private ensureSecureImplementationEnabled(operation: string): void {
if (process.env.DBIS_ALLOW_INSECURE_AS4_PLACEHOLDERS === 'true') {
return;
}
throw new Error(
`AS4 ${operation} is disabled because only placeholder cryptography is implemented. Set DBIS_ALLOW_INSECURE_AS4_PLACEHOLDERS=true for non-production testing only.`
);
}
/** /**
* Validate replay nonce * Validate replay nonce
*/ */
@@ -99,8 +109,10 @@ export class As4SecurityService {
throw new Error(`Member ${memberId} not found or no signing certificate`); throw new Error(`Member ${memberId} not found or no signing certificate`);
} }
this.ensureSecureImplementationEnabled('signing');
// TODO: Implement actual signing with HSM or certificate // TODO: Implement actual signing with HSM or certificate
// For now, create a placeholder signature // Temporary non-production fallback remains guarded by DBIS_ALLOW_INSECURE_AS4_PLACEHOLDERS.
const sign = createSign(algorithm === 'RSA-SHA256' ? 'RSA-SHA256' : 'sha256'); const sign = createSign(algorithm === 'RSA-SHA256' ? 'RSA-SHA256' : 'sha256');
sign.update(messagePayload); sign.update(messagePayload);
sign.end(); sign.end();
@@ -144,8 +156,10 @@ export class As4SecurityService {
return false; return false;
} }
this.ensureSecureImplementationEnabled('signature verification');
// TODO: Implement actual signature verification // TODO: Implement actual signature verification
// For now, verify hash-based signature // Temporary non-production fallback remains guarded by DBIS_ALLOW_INSECURE_AS4_PLACEHOLDERS.
const expectedSignature = createHash('sha256') const expectedSignature = createHash('sha256')
.update(messagePayload + member.signingCertFingerprint) .update(messagePayload + member.signingCertFingerprint)
.digest('hex'); .digest('hex');
@@ -167,8 +181,10 @@ export class As4SecurityService {
throw new Error(`Recipient ${recipientMemberId} not found or no encryption certificate`); throw new Error(`Recipient ${recipientMemberId} not found or no encryption certificate`);
} }
this.ensureSecureImplementationEnabled('encryption');
// TODO: Implement actual encryption with recipient's public key // TODO: Implement actual encryption with recipient's public key
// For now, return placeholder // Temporary non-production fallback remains guarded by DBIS_ALLOW_INSECURE_AS4_PLACEHOLDERS.
const encryptedData = Buffer.from(messagePayload).toString('base64'); const encryptedData = Buffer.from(messagePayload).toString('base64');
return { return {
@@ -200,8 +216,10 @@ export class As4SecurityService {
throw new Error('Certificate fingerprint mismatch'); throw new Error('Certificate fingerprint mismatch');
} }
this.ensureSecureImplementationEnabled('decryption');
// TODO: Implement actual decryption with private key // TODO: Implement actual decryption with private key
// For now, decode base64 // Temporary non-production fallback remains guarded by DBIS_ALLOW_INSECURE_AS4_PLACEHOLDERS.
return Buffer.from(encryptedMessage.encryptedData, 'base64').toString('utf-8'); return Buffer.from(encryptedMessage.encryptedData, 'base64').toString('utf-8');
} }

View File

@@ -17,24 +17,107 @@ import { expressionEvaluator } from './expression-evaluator';
import { entitlementsService } from '../entitlements/entitlements.service'; import { entitlementsService } from '../entitlements/entitlements.service';
import { capabilityRegistryService } from '../registry/capability-registry.service'; import { capabilityRegistryService } from '../registry/capability-registry.service';
// Redis client for caching (will be initialized if Redis is available) type PolicyRedisClient = {
let redisClient: any = null; isOpen?: boolean;
try { connect(): Promise<void>;
// Try to import Redis if available get(key: string): Promise<string | null>;
const redis = require('redis'); setEx(key: string, ttlSeconds: number, value: string): Promise<unknown>;
if (process.env.REDIS_URL) { keys(pattern: string): Promise<string[]>;
redisClient = redis.createClient({ url: process.env.REDIS_URL }); del(keys: string | string[]): Promise<unknown>;
redisClient.connect().catch(() => { on(event: 'error', listener: (error: Error) => void): void;
logger.warn('Redis connection failed, policy decisions will not be cached'); };
});
let redisClient: PolicyRedisClient | null = null;
let redisModuleChecked = false;
let redisModuleAvailable = false;
let redisConnectPromise: Promise<PolicyRedisClient | null> | null = null;
let redisMissingLogged = false;
let redisConnectionFailureLogged = false;
function loadRedisModule():
| {
createClient(options: { url: string }): PolicyRedisClient;
}
| null {
if (redisModuleChecked) {
return redisModuleAvailable ? require('redis') : null;
}
redisModuleChecked = true;
try {
const redis = require('redis') as { createClient(options: { url: string }): PolicyRedisClient };
redisModuleAvailable = true;
return redis;
} catch (error) {
if (!redisMissingLogged) {
logger.warn('Redis client module is unavailable, policy decisions will not be cached', {
error: error instanceof Error ? error.message : String(error),
});
redisMissingLogged = true;
}
redisModuleAvailable = false;
return null;
} }
} catch {
logger.warn('Redis not available, policy decisions will not be cached');
} }
export class PolicyEngineService { export class PolicyEngineService {
private readonly CACHE_TTL = 120; // 2 minutes default TTL private readonly CACHE_TTL = 120; // 2 minutes default TTL
private async getRedisClient(): Promise<PolicyRedisClient | null> {
const redisUrl = process.env.REDIS_URL?.trim();
if (!redisUrl) {
return null;
}
if (redisClient?.isOpen) {
return redisClient;
}
if (redisConnectPromise) {
return redisConnectPromise;
}
const redis = loadRedisModule();
if (!redis) {
return null;
}
redisConnectPromise = (async () => {
try {
const client = redisClient ?? redis.createClient({ url: redisUrl });
if (!redisClient) {
client.on('error', (error) => {
logger.warn('Redis client error, policy cache unavailable', {
error: error.message,
});
});
}
if (!client.isOpen) {
await client.connect();
}
redisClient = client;
redisConnectionFailureLogged = false;
return redisClient;
} catch (error) {
redisClient = null;
if (!redisConnectionFailureLogged) {
logger.warn('Redis connection failed, policy decisions will not be cached', {
error: error instanceof Error ? error.message : String(error),
});
redisConnectionFailureLogged = true;
}
return null;
} finally {
redisConnectPromise = null;
}
})();
return redisConnectPromise;
}
/** /**
* Make a policy decision * Make a policy decision
*/ */
@@ -299,12 +382,13 @@ export class PolicyEngineService {
* Get cached decision * Get cached decision
*/ */
private async getCachedDecision(cacheKey: string): Promise<PolicyDecisionResponse | null> { private async getCachedDecision(cacheKey: string): Promise<PolicyDecisionResponse | null> {
if (!redisClient) { const client = await this.getRedisClient();
if (!client) {
return null; return null;
} }
try { try {
const cached = await redisClient.get(cacheKey); const cached = await client.get(cacheKey);
if (cached) { if (cached) {
return JSON.parse(cached); return JSON.parse(cached);
} }
@@ -319,12 +403,13 @@ export class PolicyEngineService {
* Cache a decision * Cache a decision
*/ */
private async cacheDecision(cacheKey: string, decision: PolicyDecisionResponse): Promise<void> { private async cacheDecision(cacheKey: string, decision: PolicyDecisionResponse): Promise<void> {
if (!redisClient) { const client = await this.getRedisClient();
if (!client) {
return; return;
} }
try { try {
await redisClient.setEx(cacheKey, this.CACHE_TTL, JSON.stringify(decision)); await client.setEx(cacheKey, this.CACHE_TTL, JSON.stringify(decision));
} catch (error) { } catch (error) {
logger.warn('Failed to cache decision', { error }); logger.warn('Failed to cache decision', { error });
} }
@@ -334,16 +419,17 @@ export class PolicyEngineService {
* Invalidate cache for a capability * Invalidate cache for a capability
*/ */
private async invalidateCache(capabilityId: string): Promise<void> { private async invalidateCache(capabilityId: string): Promise<void> {
if (!redisClient) { const client = await this.getRedisClient();
if (!client) {
return; return;
} }
try { try {
// Use pattern matching to find all keys for this capability // Use pattern matching to find all keys for this capability
const pattern = `policy:decision:*:*:${capabilityId}:*`; const pattern = `policy:decision:*:*:${capabilityId}:*`;
const keys = await redisClient.keys(pattern); const keys = await client.keys(pattern);
if (keys.length > 0) { if (keys.length > 0) {
await redisClient.del(keys); await client.del(keys);
} }
} catch (error) { } catch (error) {
logger.warn('Failed to invalidate cache', { error }); logger.warn('Failed to invalidate cache', { error });

View File

@@ -1,13 +1,10 @@
// DBIS Core Banking System - Main Entry Point // DBIS Core Banking System - Main Entry Point
import dotenv from 'dotenv'; import './bootstrap/env';
import app from './integration/api-gateway/app'; import app from './integration/api-gateway/app';
import { logger } from './infrastructure/monitoring/logger'; import { logger } from './infrastructure/monitoring/logger';
import { validateEnvironment } from './shared/config/env-validator'; import { validateEnvironment } from './shared/config/env-validator';
// Load environment variables
dotenv.config();
// Validate environment variables before starting // Validate environment variables before starting
try { try {
validateEnvironment(); validateEnvironment();
@@ -37,4 +34,3 @@ process.on('SIGINT', () => {
logger.info('SIGINT received, shutting down gracefully'); logger.info('SIGINT received, shutting down gracefully');
process.exit(0); process.exit(0);
}); });

View File

@@ -11,6 +11,8 @@ export interface ProxmoxConfig {
username: string; username: string;
password: string; password: string;
realm?: string; realm?: string;
tokenName?: string;
tokenValue?: string;
} }
export interface ContainerSpec { export interface ContainerSpec {
@@ -57,6 +59,17 @@ export class ProxmoxVEIntegration {
* Authenticate with Proxmox VE * Authenticate with Proxmox VE
*/ */
async authenticate(): Promise<void> { async authenticate(): Promise<void> {
if (this.config.tokenName && this.config.tokenValue) {
this.token = `PVEAPIToken=${this.config.username}!${this.config.tokenName}=${this.config.tokenValue}`;
this.tokenExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000);
logger.info('Proxmox VE token authentication configured', {
host: this.config.host,
username: this.config.username,
tokenName: this.config.tokenName,
});
return;
}
await proxmoxCircuitBreaker.execute(async () => { await proxmoxCircuitBreaker.execute(async () => {
return await retryWithBackoff( return await retryWithBackoff(
async () => { async () => {
@@ -240,7 +253,9 @@ export class ProxmoxVEIntegration {
const options: RequestInit = { const options: RequestInit = {
method, method,
headers: { headers: {
'Cookie': `PVEAuthCookie=${this.token}`, ...(this.config.tokenName && this.config.tokenValue
? { Authorization: this.token || '' }
: { 'Cookie': `PVEAuthCookie=${this.token}` }),
}, },
}; };
@@ -281,19 +296,22 @@ export class ProxmoxVEIntegration {
// Validate required Proxmox environment variables // Validate required Proxmox environment variables
function getProxmoxConfig(): ProxmoxConfig { function getProxmoxConfig(): ProxmoxConfig {
const host = process.env.PROXMOX_HOST; const host = process.env.PROXMOX_HOST;
const username = process.env.PROXMOX_USERNAME; const username = process.env.PROXMOX_USERNAME || process.env.PROXMOX_USER;
const password = process.env.PROXMOX_PASSWORD; const password = process.env.PROXMOX_PASSWORD;
const tokenName = process.env.PROXMOX_TOKEN_NAME;
const tokenValue = process.env.PROXMOX_TOKEN_VALUE;
if (process.env.NODE_ENV === 'production') { const hasPasswordAuth = Boolean(host && username && password);
if (!host) { const hasTokenAuth = Boolean(host && username && tokenName && tokenValue);
throw new Error('PROXMOX_HOST environment variable is required in production');
} if (process.env.NODE_ENV === 'production' && !hasPasswordAuth && !hasTokenAuth) {
if (!username) { logger.warn('Proxmox integration environment is incomplete; IRU deployment features will remain unavailable', {
throw new Error('PROXMOX_USERNAME environment variable is required in production'); hasHost: Boolean(host),
} hasUsername: Boolean(username),
if (!password) { hasPassword: Boolean(password),
throw new Error('PROXMOX_PASSWORD environment variable is required in production'); hasTokenName: Boolean(tokenName),
} hasTokenValue: Boolean(tokenValue),
});
} }
return { return {
@@ -302,6 +320,8 @@ function getProxmoxConfig(): ProxmoxConfig {
username: username || 'root', username: username || 'root',
password: password || '', password: password || '',
realm: process.env.PROXMOX_REALM || 'pam', realm: process.env.PROXMOX_REALM || 'pam',
tokenName,
tokenValue,
}; };
} }

View File

@@ -1,28 +1,18 @@
// Express.js API Gateway Application // Express.js API Gateway Application
import '@/bootstrap/env';
import express, { Express } from 'express'; import express, { Express } from 'express';
import cors from 'cors'; import cors from 'cors';
import helmet from 'helmet'; import helmet from 'helmet';
import swaggerUi from 'swagger-ui-express'; import swaggerUi from 'swagger-ui-express';
import swaggerJsdoc from 'swagger-jsdoc'; import swaggerJsdoc from 'swagger-jsdoc';
import { zeroTrustAuthMiddleware, optionalAuthMiddleware } from './middleware/auth.middleware'; import { zeroTrustAuthMiddleware } from './middleware/auth.middleware';
import { dynamicRateLimitMiddleware } from './middleware/rate-limit.middleware'; import { dynamicRateLimitMiddleware } from './middleware/rate-limit.middleware';
import { errorHandler } from './middleware/error.middleware'; import { errorHandler } from './middleware/error.middleware';
import { auditLogMiddleware } from './middleware/audit.middleware'; import { auditLogMiddleware } from './middleware/audit.middleware';
import { validateEnvironment } from '@/shared/config/env-validator';
import { logger } from '@/infrastructure/monitoring/logger'; import { logger } from '@/infrastructure/monitoring/logger';
import { tracingMiddleware } from '@/infrastructure/monitoring/tracing.middleware'; import { tracingMiddleware } from '@/infrastructure/monitoring/tracing.middleware';
import authRoutes from './routes/auth.routes';
// Validate environment variables at startup (fail fast)
try {
validateEnvironment();
logger.info('Environment validation passed');
} catch (error) {
logger.error('Environment validation failed', {
error: error instanceof Error ? error.message : 'Unknown error',
});
process.exit(1);
}
// Import route handlers (will be created) // Import route handlers (will be created)
// import paymentRoutes from '@/core/payments/payment.routes'; // import paymentRoutes from '@/core/payments/payment.routes';
@@ -285,6 +275,9 @@ app.get(['/health', '/v1/health'], async (req, res) => {
res.status(statusCode).json(healthStatus); res.status(statusCode).json(healthStatus);
}); });
// Portal auth routes: public login plus session bootstrap/logout.
app.use('/api/auth', authRoutes);
// IRU Marketplace routes (public endpoints, auth handled per-route) // IRU Marketplace routes (public endpoints, auth handled per-route)
app.use('/api/v1/iru/marketplace', iruMarketplaceRoutes); app.use('/api/v1/iru/marketplace', iruMarketplaceRoutes);
@@ -320,8 +313,11 @@ app.use('/api/v1/iru/metrics', iruMetricsRoutes);
import adminCentralRoutes from '@/integration/api-gateway/routes/admin-central.routes'; import adminCentralRoutes from '@/integration/api-gateway/routes/admin-central.routes';
app.use('/api/admin/central', adminCentralRoutes); app.use('/api/admin/central', adminCentralRoutes);
// Public AS4 metrics route for Prometheus.
import as4MetricsRoutes from '@/core/settlement/as4/as4-metrics.routes';
app.use('/api/v1/as4', as4MetricsRoutes);
// API routes (protected) // API routes (protected)
// All API routes require authentication
app.use('/api', zeroTrustAuthMiddleware); app.use('/api', zeroTrustAuthMiddleware);
app.use('/api', dynamicRateLimitMiddleware); app.use('/api', dynamicRateLimitMiddleware);
@@ -365,11 +361,9 @@ app.use('/api/v1/routes', tezosUsdtzRoutes);
import as4GatewayRoutes from '@/core/settlement/as4/as4.routes'; import as4GatewayRoutes from '@/core/settlement/as4/as4.routes';
import as4MemberDirectoryRoutes from '@/core/settlement/as4-settlement/member-directory/member-directory.routes'; import as4MemberDirectoryRoutes from '@/core/settlement/as4-settlement/member-directory/member-directory.routes';
import as4SettlementRoutes from '@/core/settlement/as4-settlement/as4-settlement.routes'; import as4SettlementRoutes from '@/core/settlement/as4-settlement/as4-settlement.routes';
import as4MetricsRoutes from '@/core/settlement/as4/as4-metrics.routes';
app.use('/api/v1/as4/gateway', as4GatewayRoutes); app.use('/api/v1/as4/gateway', as4GatewayRoutes);
app.use('/api/v1/as4/directory', as4MemberDirectoryRoutes); app.use('/api/v1/as4/directory', as4MemberDirectoryRoutes);
app.use('/api/v1/as4/settlement', as4SettlementRoutes); app.use('/api/v1/as4/settlement', as4SettlementRoutes);
app.use('/api/v1/as4', as4MetricsRoutes); // Metrics endpoint (public for Prometheus)
// Volume V routes // Volume V routes
app.use('/api/v1/gbig', gbigRoutes); app.use('/api/v1/gbig', gbigRoutes);

View File

@@ -12,17 +12,19 @@ export function requireAdminCentralKey(req: Request, res: Response, next: NextFu
if (!expected) { if (!expected) {
// If not configured, allow (dev) or deny (prod). Prefer deny for security. // If not configured, allow (dev) or deny (prod). Prefer deny for security.
return res.status(501).json({ res.status(501).json({
success: false, success: false,
error: { code: 'NOT_CONFIGURED', message: 'Admin central API key not configured' }, error: { code: 'NOT_CONFIGURED', message: 'Admin central API key not configured' },
}); });
return;
} }
if (!key || key !== expected) { if (!key || key !== expected) {
return res.status(401).json({ res.status(401).json({
success: false, success: false,
error: { code: 'UNAUTHORIZED', message: 'Invalid or missing X-Admin-Central-Key' }, error: { code: 'UNAUTHORIZED', message: 'Invalid or missing X-Admin-Central-Key' },
}); });
return;
} }
next(); next();

View File

@@ -13,13 +13,11 @@ import { DbisError, ErrorCode } from '@/shared/types';
export function requireAdminPermission(permission: AdminPermission) { export function requireAdminPermission(permission: AdminPermission) {
return async (req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> => { return async (req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> => {
try { try {
// Get employee ID from header or use sovereignBankId as fallback const employeeId = req.employeeId;
// In production, this would come from JWT token or employee credential
const employeeId = (req.headers['x-employee-id'] as string) || req.sovereignBankId;
if (!employeeId) { if (!employeeId) {
throw new DbisError( throw new DbisError(
ErrorCode.UNAUTHORIZED, ErrorCode.UNAUTHORIZED,
'Employee ID or Sovereign Bank ID required for admin operations' 'Employee identity required for admin operations'
); );
} }
@@ -37,7 +35,7 @@ export function requireAdminPermission(permission: AdminPermission) {
next(); next();
} catch (error) { } catch (error) {
if (error instanceof DbisError) { if (error instanceof DbisError) {
return res.status(error.code === ErrorCode.FORBIDDEN ? 403 : 401).json({ res.status(error.code === ErrorCode.FORBIDDEN ? 403 : 401).json({
success: false, success: false,
error: { error: {
code: error.code, code: error.code,
@@ -45,8 +43,9 @@ export function requireAdminPermission(permission: AdminPermission) {
}, },
timestamp: new Date(), timestamp: new Date(),
}); });
return;
} else { } else {
return res.status(500).json({ res.status(500).json({
success: false, success: false,
error: { error: {
code: ErrorCode.INTERNAL_ERROR, code: ErrorCode.INTERNAL_ERROR,
@@ -54,6 +53,7 @@ export function requireAdminPermission(permission: AdminPermission) {
}, },
timestamp: new Date(), timestamp: new Date(),
}); });
return;
} }
} }
}; };
@@ -65,11 +65,11 @@ export function requireAdminPermission(permission: AdminPermission) {
export function requireSCBAccess(targetSCBIdParam: string = 'scbId') { export function requireSCBAccess(targetSCBIdParam: string = 'scbId') {
return async (req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> => { return async (req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> => {
try { try {
const employeeId = (req.headers['x-employee-id'] as string) || req.sovereignBankId; const employeeId = req.employeeId;
if (!employeeId) { if (!employeeId) {
throw new DbisError( throw new DbisError(
ErrorCode.UNAUTHORIZED, ErrorCode.UNAUTHORIZED,
'Employee ID or Sovereign Bank ID required' 'Employee identity required'
); );
} }
@@ -92,7 +92,7 @@ export function requireSCBAccess(targetSCBIdParam: string = 'scbId') {
next(); next();
} catch (error) { } catch (error) {
if (error instanceof DbisError) { if (error instanceof DbisError) {
return res.status(error.code === ErrorCode.FORBIDDEN ? 403 : 401).json({ res.status(error.code === ErrorCode.FORBIDDEN ? 403 : 401).json({
success: false, success: false,
error: { error: {
code: error.code, code: error.code,
@@ -100,8 +100,9 @@ export function requireSCBAccess(targetSCBIdParam: string = 'scbId') {
}, },
timestamp: new Date(), timestamp: new Date(),
}); });
return;
} else { } else {
return res.status(500).json({ res.status(500).json({
success: false, success: false,
error: { error: {
code: ErrorCode.INTERNAL_ERROR, code: ErrorCode.INTERNAL_ERROR,
@@ -109,6 +110,7 @@ export function requireSCBAccess(targetSCBIdParam: string = 'scbId') {
}, },
timestamp: new Date(), timestamp: new Date(),
}); });
return;
} }
} }
}; };

View File

@@ -10,10 +10,21 @@ import { getEnv } from '@/shared/config/env-validator';
export interface AuthenticatedRequest extends Request { export interface AuthenticatedRequest extends Request {
sovereignBankId?: string; sovereignBankId?: string;
employeeId?: string;
email?: string;
name?: string;
roleName?: string;
permissions?: string[];
sessionType?: 'portal' | 'service';
portalSurface?: 'admin' | 'member' | 'core';
identityType?: string; identityType?: string;
apiRole?: string; apiRole?: string;
} }
function isPortalSession(payload: JwtPayload): boolean {
return payload.sessionType === 'portal' || payload.identityType === 'WEB_PORTAL';
}
/** /**
* Extract Sovereign Identity Token (SIT) from Authorization header * Extract Sovereign Identity Token (SIT) from Authorization header
*/ */
@@ -144,23 +155,43 @@ export async function zeroTrustAuthMiddleware(
throw new DbisError(ErrorCode.UNAUTHORIZED, 'Invalid or expired token'); throw new DbisError(ErrorCode.UNAUTHORIZED, 'Invalid or expired token');
} }
// Extract sovereign bank ID and identity type // Extract claims shared by service and portal sessions
req.sovereignBankId = decoded.sovereignBankId; req.sovereignBankId = decoded.sovereignBankId;
req.employeeId = typeof decoded.employeeId === 'string' ? decoded.employeeId : undefined;
req.email = typeof decoded.email === 'string' ? decoded.email : undefined;
req.name = typeof decoded.name === 'string' ? decoded.name : undefined;
req.roleName = typeof decoded.roleName === 'string' ? decoded.roleName : undefined;
req.permissions = Array.isArray(decoded.permissions)
? decoded.permissions.filter((permission): permission is string => typeof permission === 'string')
: undefined;
req.sessionType = decoded.sessionType === 'portal' ? 'portal' : 'service';
req.portalSurface =
decoded.portalSurface === 'admin' ||
decoded.portalSurface === 'member' ||
decoded.portalSurface === 'core'
? decoded.portalSurface
: undefined;
req.identityType = decoded.identityType; req.identityType = decoded.identityType;
req.apiRole = decoded.apiRole; req.apiRole = decoded.apiRole;
if (!req.sovereignBankId) { if (isPortalSession(decoded)) {
throw new DbisError(ErrorCode.UNAUTHORIZED, 'Invalid token payload'); if (!req.employeeId && !req.email) {
} throw new DbisError(ErrorCode.UNAUTHORIZED, 'Invalid portal token payload');
}
} else {
if (!req.sovereignBankId) {
throw new DbisError(ErrorCode.UNAUTHORIZED, 'Invalid token payload');
}
// Verify request signature // Verify request signature
const signatureValid = await verifyRequestSignature( const signatureValid = await verifyRequestSignature(
req, req,
req.sovereignBankId, req.sovereignBankId,
req.identityType || '' req.identityType || ''
); );
if (!signatureValid) { if (!signatureValid) {
throw new DbisError(ErrorCode.UNAUTHORIZED, 'Invalid request signature'); throw new DbisError(ErrorCode.UNAUTHORIZED, 'Invalid request signature');
}
} }
// Check token expiration // Check token expiration
@@ -207,6 +238,20 @@ export function optionalAuthMiddleware(
if (jwtSecret) { if (jwtSecret) {
const decoded = jwt.verify(token, jwtSecret) as JwtPayload; const decoded = jwt.verify(token, jwtSecret) as JwtPayload;
req.sovereignBankId = decoded.sovereignBankId; req.sovereignBankId = decoded.sovereignBankId;
req.employeeId = typeof decoded.employeeId === 'string' ? decoded.employeeId : undefined;
req.email = typeof decoded.email === 'string' ? decoded.email : undefined;
req.name = typeof decoded.name === 'string' ? decoded.name : undefined;
req.roleName = typeof decoded.roleName === 'string' ? decoded.roleName : undefined;
req.permissions = Array.isArray(decoded.permissions)
? decoded.permissions.filter((permission): permission is string => typeof permission === 'string')
: undefined;
req.sessionType = decoded.sessionType === 'portal' ? 'portal' : 'service';
req.portalSurface =
decoded.portalSurface === 'admin' ||
decoded.portalSurface === 'member' ||
decoded.portalSurface === 'core'
? decoded.portalSurface
: undefined;
req.identityType = decoded.identityType; req.identityType = decoded.identityType;
req.apiRole = decoded.apiRole; req.apiRole = decoded.apiRole;
} }
@@ -219,4 +264,3 @@ export function optionalAuthMiddleware(
} }
next(); next();
} }

View File

@@ -31,6 +31,12 @@ export function createRateLimiter(tier: 'TIER_1' | 'TIER_2' | 'PRIVATE_BANK') {
}); });
} }
const tierLimiters = {
TIER_1: createRateLimiter('TIER_1'),
TIER_2: createRateLimiter('TIER_2'),
PRIVATE_BANK: createRateLimiter('PRIVATE_BANK'),
} as const;
/** /**
* Dynamic rate limiter based on user role * Dynamic rate limiter based on user role
*/ */
@@ -48,7 +54,7 @@ export function dynamicRateLimitMiddleware(
tier = 'TIER_2'; tier = 'TIER_2';
} }
const limiter = createRateLimiter(tier); const limiter = tierLimiters[tier];
limiter(req, res, next); limiter(req, res, next);
} }

View File

@@ -128,3 +128,94 @@ export const iruValidationSchemas = {
id: z.string().min(1, 'ID is required'), id: z.string().min(1, 'ID is required'),
}), }),
}; };
export const nostroVostroValidationSchemas = {
participantCreateRequest: z.object({
participantId: z.string().min(1, 'Participant ID is required').optional(),
name: z.string().min(1, 'Institution name is required'),
bic: z
.string()
.regex(/^[A-Za-z0-9]{8}([A-Za-z0-9]{3})?$/, 'BIC must be 8 or 11 alphanumeric characters')
.optional(),
lei: z
.string()
.trim()
.min(20, 'LEI is required')
.max(20, 'LEI must be exactly 20 characters'),
country: z
.string()
.trim()
.length(2, 'Country must be a 2-letter ISO code')
.transform((value) => value.toUpperCase()),
regulatoryTier: z.enum(['SCB', 'Tier1', 'Tier2', 'PSP']),
sovereignBankId: z.string().min(1, 'Sovereign bank ID is required').optional(),
metadata: z.record(z.any()).optional(),
}),
};
export const authValidationSchemas = {
loginRequest: z.object({
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
otp: z
.string()
.trim()
.regex(/^\d{6}$/, 'OTP must be a 6-digit code')
.optional(),
surface: z.enum(['admin', 'member', 'core']).optional(),
}),
passwordChangeRequest: z.object({
currentPassword: z.string().min(1, 'Current password is required'),
newPassword: z.string().min(1, 'New password is required'),
}),
passwordResetCompleteRequest: z.object({
username: z.string().min(1, 'Username is required'),
resetToken: z.string().min(1, 'Reset token is required'),
newPassword: z.string().min(1, 'New password is required'),
surface: z.enum(['admin', 'member', 'core']).optional(),
}),
passwordResetRequest: z.object({
username: z.string().min(1, 'Username is required'),
surface: z.enum(['admin', 'member', 'core']).optional(),
}),
mfaSetupEnableRequest: z.object({
secret: z.string().min(16, 'MFA secret is required'),
otp: z.string().trim().regex(/^\d{6}$/, 'OTP must be a 6-digit code'),
}),
mfaDisableRequest: z.object({
otp: z.string().trim().regex(/^\d{6}$/, 'OTP must be a 6-digit code'),
}),
issueEmployeeAccountRequest: z.object({
employeeId: z.string().min(1, 'Employee ID is required'),
employeeName: z.string().min(1, 'Employee name is required'),
email: z.string().email('Valid email is required'),
roleName: z.string().min(1, 'Role name is required'),
securityClearance: z.string().min(1, 'Security clearance is required'),
password: z.string().min(1, 'Password is required'),
mustRotatePassword: z.boolean().optional(),
}),
issueMemberAccountRequest: z.object({
memberId: z.string().min(1, 'Member ID is required'),
memberName: z.string().min(1, 'Member name is required'),
email: z.string().email('Valid email is required'),
institutionName: z.string().min(1, 'Institution name is required'),
institutionCountry: z
.string()
.trim()
.length(2, 'Institution country must be a 2-letter ISO code')
.transform((value) => value.toUpperCase()),
participantId: z.string().min(1, 'Participant ID is required').optional(),
lei: z.string().trim().length(20, 'LEI must be exactly 20 characters').optional(),
sovereignBankId: z.string().min(1, 'Sovereign bank ID is required').optional(),
password: z.string().min(1, 'Password is required'),
mustRotatePassword: z.boolean().optional(),
}),
issueResetTokenRequest: z.object({
accountType: z.enum(['employee', 'member']),
identifier: z.string().min(1, 'Identifier is required'),
}),
deactivateAccountRequest: z.object({
accountType: z.enum(['employee', 'member']),
identifier: z.string().min(1, 'Identifier is required'),
}),
};

View File

@@ -0,0 +1,310 @@
import { Router } from 'express';
import { zeroTrustAuthMiddleware, type AuthenticatedRequest } from '../middleware/auth.middleware';
import { portalAuthService } from '../services/portal-auth.service';
import { logger } from '@/infrastructure/monitoring/logger';
import { DbisError, ErrorCode } from '@/shared/types';
import { validateRequest, authValidationSchemas } from '../middleware/validation.middleware';
import { requireAdminPermission } from '../middleware/admin-permission.middleware';
import { AdminPermission } from '@/core/admin/shared/permissions.constants';
const router = Router();
function handleAuthRouteError(error: unknown, res: any, fallbackStatus = 400) {
if (error instanceof DbisError) {
return res.status(error.code === ErrorCode.FORBIDDEN ? 403 : 401).json({
success: false,
error: {
code: error.code,
message: error.message,
},
timestamp: new Date(),
});
}
const message = error instanceof Error ? error.message : 'Unexpected error';
const status = /not found/i.test(message)
? 404
: /locked|expired|pending|invalid|incorrect|required|configured/i.test(message)
? 400
: fallbackStatus;
return res.status(status).json({
success: false,
error: {
code: status === 404 ? ErrorCode.NOT_FOUND : ErrorCode.VALIDATION_ERROR,
message,
},
timestamp: new Date(),
});
}
router.post(
'/login',
validateRequest({ body: authValidationSchemas.loginRequest }),
async (req, res, next) => {
try {
const { username, password, otp, surface } = req.body || {};
const result = await portalAuthService.login({
username,
password,
otp,
surface,
});
res.json(result);
} catch (error) {
if (error instanceof DbisError) {
return next(error);
}
logger.warn('Portal login rejected', {
surface: req.body?.surface || 'admin',
username: req.body?.username,
error: error instanceof Error ? error.message : String(error),
});
return res.status(401).json({
success: false,
error: {
code: ErrorCode.UNAUTHORIZED,
message: error instanceof Error ? error.message : 'Invalid credentials',
},
timestamp: new Date(),
});
}
}
);
router.get('/me', zeroTrustAuthMiddleware, async (req: AuthenticatedRequest, res) => {
res.json({
user: portalAuthService.buildPortalUserFromClaims({
employeeId: req.employeeId,
email: req.email,
name: req.name,
roleName: req.roleName,
permissions: req.permissions,
sovereignBankId: req.sovereignBankId,
identityType: req.identityType || 'WEB_PORTAL',
sessionType: req.sessionType || 'portal',
portalSurface: req.portalSurface,
apiRole: req.apiRole,
}),
});
});
router.post('/logout', zeroTrustAuthMiddleware, async (_req, res) => {
res.status(204).send();
});
router.post(
'/password/change',
zeroTrustAuthMiddleware,
validateRequest({ body: authValidationSchemas.passwordChangeRequest }),
async (req: AuthenticatedRequest, res) => {
try {
await portalAuthService.changePassword({
portalSurface: req.portalSurface,
employeeId: req.employeeId,
currentPassword: req.body.currentPassword,
newPassword: req.body.newPassword,
});
return res.status(204).send();
} catch (error) {
return handleAuthRouteError(error, res);
}
}
);
router.post(
'/password/reset/request',
validateRequest({ body: authValidationSchemas.passwordResetRequest }),
async (req, res) => {
logger.info('Portal password reset requested', {
username: req.body.username,
surface: req.body.surface || 'admin',
});
return res.json({
success: true,
message: 'Password reset request recorded for administrator review',
});
}
);
router.post(
'/password/reset/complete',
validateRequest({ body: authValidationSchemas.passwordResetCompleteRequest }),
async (req, res) => {
try {
await portalAuthService.completePasswordReset({
username: req.body.username,
resetToken: req.body.resetToken,
newPassword: req.body.newPassword,
surface: req.body.surface,
});
return res.status(204).send();
} catch (error) {
return handleAuthRouteError(error, res);
}
}
);
router.get('/mfa/status', zeroTrustAuthMiddleware, async (req: AuthenticatedRequest, res) => {
try {
if (!req.employeeId || req.portalSurface === 'member') {
throw new Error('MFA is only available for employee portal accounts');
}
const status = await portalAuthService.getEmployeeMfaStatus(req.employeeId);
return res.json(status);
} catch (error) {
return handleAuthRouteError(error, res);
}
});
router.post('/mfa/setup', zeroTrustAuthMiddleware, async (req: AuthenticatedRequest, res) => {
try {
if (!req.employeeId || req.portalSurface === 'member') {
throw new Error('MFA is only available for employee portal accounts');
}
const enrollment = await portalAuthService.beginEmployeeMfaEnrollment(req.employeeId);
return res.json(enrollment);
} catch (error) {
return handleAuthRouteError(error, res);
}
});
router.post(
'/mfa/enable',
zeroTrustAuthMiddleware,
validateRequest({ body: authValidationSchemas.mfaSetupEnableRequest }),
async (req: AuthenticatedRequest, res) => {
try {
if (!req.employeeId || req.portalSurface === 'member') {
throw new Error('MFA is only available for employee portal accounts');
}
await portalAuthService.enableEmployeeMfa(req.employeeId, req.body.secret, req.body.otp);
return res.status(204).send();
} catch (error) {
return handleAuthRouteError(error, res);
}
}
);
router.post(
'/mfa/disable',
zeroTrustAuthMiddleware,
validateRequest({ body: authValidationSchemas.mfaDisableRequest }),
async (req: AuthenticatedRequest, res) => {
try {
if (!req.employeeId || req.portalSurface === 'member') {
throw new Error('MFA is only available for employee portal accounts');
}
await portalAuthService.disableEmployeeMfa(req.employeeId, req.body.otp);
return res.status(204).send();
} catch (error) {
return handleAuthRouteError(error, res);
}
}
);
router.post(
'/admin/accounts/employee',
zeroTrustAuthMiddleware,
requireAdminPermission(AdminPermission.RBAC_EDIT),
validateRequest({ body: authValidationSchemas.issueEmployeeAccountRequest }),
async (req: AuthenticatedRequest, res) => {
try {
const issued = await portalAuthService.issueEmployeeAccount(req.body);
return res.status(201).json(issued);
} catch (error) {
return handleAuthRouteError(error, res);
}
}
);
router.get(
'/admin/accounts/member',
zeroTrustAuthMiddleware,
requireAdminPermission(AdminPermission.RBAC_EDIT),
async (req, res) => {
try {
const memberAccounts = await portalAuthService.listMemberAccounts({
approvalStatus:
typeof req.query.approvalStatus === 'string' ? req.query.approvalStatus : undefined,
participantId:
typeof req.query.participantId === 'string' ? req.query.participantId : undefined,
});
return res.json(memberAccounts);
} catch (error) {
return handleAuthRouteError(error, res);
}
}
);
router.post(
'/admin/accounts/member',
zeroTrustAuthMiddleware,
requireAdminPermission(AdminPermission.RBAC_EDIT),
validateRequest({ body: authValidationSchemas.issueMemberAccountRequest }),
async (req: AuthenticatedRequest, res) => {
try {
const issued = await portalAuthService.issueMemberAccount(req.body);
return res.status(201).json(issued);
} catch (error) {
return handleAuthRouteError(error, res);
}
}
);
router.post(
'/admin/accounts/member/:memberId/approve',
zeroTrustAuthMiddleware,
requireAdminPermission(AdminPermission.RBAC_EDIT),
async (req: AuthenticatedRequest, res) => {
try {
if (!req.employeeId) {
throw new Error('Employee identity required');
}
await portalAuthService.approveMemberAccount(req.params.memberId, req.employeeId);
return res.status(204).send();
} catch (error) {
return handleAuthRouteError(error, res);
}
}
);
router.post(
'/admin/password-reset/issue',
zeroTrustAuthMiddleware,
requireAdminPermission(AdminPermission.RBAC_EDIT),
validateRequest({ body: authValidationSchemas.issueResetTokenRequest }),
async (req, res) => {
try {
const token = await portalAuthService.issuePasswordResetToken(req.body);
return res.status(201).json(token);
} catch (error) {
return handleAuthRouteError(error, res);
}
}
);
router.post(
'/admin/accounts/deactivate',
zeroTrustAuthMiddleware,
requireAdminPermission(AdminPermission.RBAC_EDIT),
validateRequest({ body: authValidationSchemas.deactivateAccountRequest }),
async (req, res) => {
try {
await portalAuthService.deactivateAccount(req.body);
return res.status(204).send();
} catch (error) {
return handleAuthRouteError(error, res);
}
}
);
export default router;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,171 @@
import crypto from 'crypto';
import bcrypt from 'bcryptjs';
import { getEnv } from '@/shared/config/env-validator';
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
function base32Encode(buffer: Buffer): string {
let bits = 0;
let value = 0;
let output = '';
for (const byte of buffer) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) {
output += BASE32_ALPHABET[(value << (5 - bits)) & 31];
}
return output;
}
function base32Decode(value: string): Buffer {
const normalized = value.replace(/=+$/g, '').replace(/[\s-]/g, '').toUpperCase();
let bits = 0;
let accumulator = 0;
const bytes: number[] = [];
for (const char of normalized) {
const index = BASE32_ALPHABET.indexOf(char);
if (index === -1) {
throw new Error('Invalid base32 secret');
}
accumulator = (accumulator << 5) | index;
bits += 5;
if (bits >= 8) {
bytes.push((accumulator >>> (bits - 8)) & 0xff);
bits -= 8;
}
}
return Buffer.from(bytes);
}
function hotp(secret: Buffer, counter: number, digits = 6): string {
const counterBuffer = Buffer.alloc(8);
counterBuffer.writeBigUInt64BE(BigInt(counter));
const digest = crypto.createHmac('sha1', secret).update(counterBuffer).digest();
const offset = digest[digest.length - 1] & 0x0f;
const code = ((digest[offset] & 0x7f) << 24)
| ((digest[offset + 1] & 0xff) << 16)
| ((digest[offset + 2] & 0xff) << 8)
| (digest[offset + 3] & 0xff);
return (code % 10 ** digits).toString().padStart(digits, '0');
}
export class PortalSecurityService {
private readonly encryptionKey = crypto
.createHash('sha256')
.update(getEnv('JWT_SECRET'))
.digest();
hashPassword(password: string): string {
this.ensureStrongPassword(password);
return bcrypt.hashSync(password, 12);
}
verifyPassword(password: string, passwordHash: string): boolean {
return bcrypt.compareSync(password, passwordHash);
}
ensureStrongPassword(password: string): void {
const trimmed = password.trim();
if (trimmed.length < 14) {
throw new Error('Password must be at least 14 characters long');
}
const checks = [
/[a-z]/.test(trimmed),
/[A-Z]/.test(trimmed),
/\d/.test(trimmed),
/[^A-Za-z0-9]/.test(trimmed),
];
if (checks.some((passed) => !passed)) {
throw new Error(
'Password must include upper-case, lower-case, numeric, and special characters'
);
}
}
generateResetToken(): { plaintext: string; hash: string } {
const plaintext = crypto.randomBytes(24).toString('base64url');
return {
plaintext,
hash: this.hashResetToken(plaintext),
};
}
hashResetToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
generateTotpSecret(): string {
return base32Encode(crypto.randomBytes(20));
}
generateTotpUri(label: string, secret: string): string {
const issuer = encodeURIComponent(process.env.DBIS_PORTAL_MFA_ISSUER || 'DBIS Portal');
const account = encodeURIComponent(label);
return `otpauth://totp/${issuer}:${account}?secret=${secret}&issuer=${issuer}&algorithm=SHA1&digits=6&period=30`;
}
verifyTotp(secret: string, code: string, window = 1): boolean {
const normalizedCode = code.replace(/\s+/g, '');
if (!/^\d{6}$/.test(normalizedCode)) {
return false;
}
const secretBuffer = base32Decode(secret);
const currentCounter = Math.floor(Date.now() / 1000 / 30);
for (let offset = -window; offset <= window; offset += 1) {
const candidate = hotp(secretBuffer, currentCounter + offset);
if (
crypto.timingSafeEqual(Buffer.from(candidate), Buffer.from(normalizedCode))
) {
return true;
}
}
return false;
}
encryptMfaSecret(secret: string): { ciphertext: string; iv: string; tag: string } {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', this.encryptionKey, iv);
const ciphertext = Buffer.concat([cipher.update(secret, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return {
ciphertext: ciphertext.toString('base64'),
iv: iv.toString('base64'),
tag: tag.toString('base64'),
};
}
decryptMfaSecret(ciphertext: string, iv: string, tag: string): string {
const decipher = crypto.createDecipheriv(
'aes-256-gcm',
this.encryptionKey,
Buffer.from(iv, 'base64')
);
decipher.setAuthTag(Buffer.from(tag, 'base64'));
const plaintext = Buffer.concat([
decipher.update(Buffer.from(ciphertext, 'base64')),
decipher.final(),
]);
return plaintext.toString('utf8');
}
}
export const portalSecurityService = new PortalSecurityService();

View File

@@ -46,17 +46,17 @@ export class HSMService {
let publicKey: string; let publicKey: string;
if (keyType === 'ECC-521') { if (keyType === 'ECC-521') {
const { publicKey: pubKey } = crypto.generateKeyPairSync('ec', { const ecOptions: crypto.ECKeyPairKeyObjectOptions = {
namedCurve: 'secp521r1', namedCurve: 'secp521r1',
publicKeyEncoding: { type: 'spki', format: 'pem' }, };
}); const { publicKey: pubKey } = crypto.generateKeyPairSync('ec', ecOptions);
publicKey = pubKey; publicKey = pubKey.export({ type: 'spki', format: 'pem' }).toString();
} else { } else {
const { publicKey: pubKey } = crypto.generateKeyPairSync('rsa', { const rsaOptions: crypto.RSAKeyPairKeyObjectOptions = {
modulusLength: 4096, modulusLength: 4096,
publicKeyEncoding: { type: 'spki', format: 'pem' }, };
}); const { publicKey: pubKey } = crypto.generateKeyPairSync('rsa', rsaOptions);
publicKey = pubKey; publicKey = pubKey.export({ type: 'spki', format: 'pem' }).toString();
} }
const key: HSMKey = { const key: HSMKey = {
@@ -180,4 +180,3 @@ export class HSMService {
} }
export const hsmService = new HSMService(); export const hsmService = new HSMService();

View File

@@ -100,7 +100,7 @@ const envConfig: EnvConfig = {
// IRU - Proxmox VE Configuration // IRU - Proxmox VE Configuration
PROXMOX_HOST: { PROXMOX_HOST: {
required: process.env.NODE_ENV === 'production', required: false,
description: 'Proxmox VE host address', description: 'Proxmox VE host address',
validator: (value) => { validator: (value) => {
try { try {
@@ -113,13 +113,13 @@ const envConfig: EnvConfig = {
errorMessage: 'PROXMOX_HOST must be a valid hostname or IP address', errorMessage: 'PROXMOX_HOST must be a valid hostname or IP address',
}, },
PROXMOX_USERNAME: { PROXMOX_USERNAME: {
required: process.env.NODE_ENV === 'production', required: false,
description: 'Proxmox VE username', description: 'Proxmox VE username',
validator: (value) => value.length > 0, validator: (value) => value.length > 0,
errorMessage: 'PROXMOX_USERNAME cannot be empty', errorMessage: 'PROXMOX_USERNAME cannot be empty',
}, },
PROXMOX_PASSWORD: { PROXMOX_PASSWORD: {
required: process.env.NODE_ENV === 'production', required: false,
description: 'Proxmox VE password', description: 'Proxmox VE password',
validator: (value) => value.length >= 8, validator: (value) => value.length >= 8,
errorMessage: 'PROXMOX_PASSWORD must be at least 8 characters long', errorMessage: 'PROXMOX_PASSWORD must be at least 8 characters long',
@@ -231,6 +231,128 @@ const envConfig: EnvConfig = {
}, },
errorMessage: 'PROMETHEUS_PUSH_GATEWAY must be a valid URL', errorMessage: 'PROMETHEUS_PUSH_GATEWAY must be a valid URL',
}, },
DBIS_PORTAL_SHARED_SECRET: {
required: false,
description: 'Legacy bootstrap-only employee portal secret used only when generating a portalPasswordHash',
validator: (value) => value.length >= 12,
errorMessage: 'DBIS_PORTAL_SHARED_SECRET must be at least 12 characters long',
},
DBIS_BOOTSTRAP_EMPLOYEE_PASSWORD: {
required: false,
description: 'Bootstrap employee portal password used to generate portalPasswordHash during auth bootstrap',
validator: (value) => value.length >= 12,
errorMessage: 'DBIS_BOOTSTRAP_EMPLOYEE_PASSWORD must be at least 12 characters long',
},
DBIS_BOOTSTRAP_EMPLOYEE_PASSWORD_HASH: {
required: false,
description: 'bcrypt hash for the bootstrap employee portal password',
validator: (value) => value.startsWith('$2'),
errorMessage: 'DBIS_BOOTSTRAP_EMPLOYEE_PASSWORD_HASH must be a bcrypt hash',
},
DBIS_MEMBER_PORTAL_SHARED_SECRET: {
required: false,
description: 'Legacy bootstrap-only member portal secret used only when generating a member portalPasswordHash',
validator: (value) => value.length >= 12,
errorMessage: 'DBIS_MEMBER_PORTAL_SHARED_SECRET must be at least 12 characters long',
},
DBIS_BOOTSTRAP_MEMBER_PASSWORD: {
required: false,
description: 'Bootstrap member portal password used to generate portalPasswordHash during member auth bootstrap',
validator: (value) => value.length >= 12,
errorMessage: 'DBIS_BOOTSTRAP_MEMBER_PASSWORD must be at least 12 characters long',
},
DBIS_BOOTSTRAP_MEMBER_PASSWORD_HASH: {
required: false,
description: 'bcrypt hash for the bootstrap member portal password',
validator: (value) => value.startsWith('$2'),
errorMessage: 'DBIS_BOOTSTRAP_MEMBER_PASSWORD_HASH must be a bcrypt hash',
},
DBIS_PORTAL_TOKEN_TTL: {
required: false,
description: 'JWT TTL for portal sessions (e.g. 8h, 30m)',
validator: (value) => /^[0-9]+[smhd]$/.test(value),
errorMessage: 'DBIS_PORTAL_TOKEN_TTL must look like 30m, 8h, or 7d',
},
DBIS_PORTAL_MAX_FAILED_ATTEMPTS: {
required: false,
description: 'Maximum failed portal login attempts before temporary lockout',
validator: (value) => /^\d+$/.test(value) && Number(value) >= 3 && Number(value) <= 20,
errorMessage: 'DBIS_PORTAL_MAX_FAILED_ATTEMPTS must be an integer between 3 and 20',
},
DBIS_PORTAL_LOCKOUT_MINUTES: {
required: false,
description: 'Portal account lockout duration in minutes after too many failed logins',
validator: (value) => /^\d+$/.test(value) && Number(value) >= 1 && Number(value) <= 1440,
errorMessage: 'DBIS_PORTAL_LOCKOUT_MINUTES must be an integer between 1 and 1440',
},
DBIS_PORTAL_RESET_TOKEN_TTL_MINUTES: {
required: false,
description: 'Portal password reset token lifetime in minutes',
validator: (value) => /^\d+$/.test(value) && Number(value) >= 5 && Number(value) <= 1440,
errorMessage: 'DBIS_PORTAL_RESET_TOKEN_TTL_MINUTES must be an integer between 5 and 1440',
},
DBIS_PORTAL_MFA_ISSUER: {
required: false,
description: 'Issuer label used in authenticator-app TOTP enrollment URIs',
validator: (value) => value.trim().length >= 2,
errorMessage: 'DBIS_PORTAL_MFA_ISSUER must be at least 2 characters long',
},
DBIS_PORTAL_EMPLOYEE_SCOPE_JSON: {
required: false,
description: 'JSON object mapping employee IDs or emails to sovereign-bank IDs',
validator: (value) => {
try {
const parsed = JSON.parse(value);
return Boolean(parsed) && typeof parsed === 'object' && !Array.isArray(parsed);
} catch {
return false;
}
},
errorMessage: 'DBIS_PORTAL_EMPLOYEE_SCOPE_JSON must be a JSON object',
},
DBIS_ENABLE_PLACEHOLDER_METRICS: {
required: false,
description: 'Allow admin dashboards to emit placeholder metrics',
validator: (value) => value === 'true' || value === 'false',
errorMessage: 'DBIS_ENABLE_PLACEHOLDER_METRICS must be "true" or "false"',
},
DBIS_ALLOW_INSECURE_AS4_PLACEHOLDERS: {
required: false,
description: 'Permit placeholder AS4 signing/encryption implementations for non-production use',
validator: (value) => value === 'true' || value === 'false',
errorMessage: 'DBIS_ALLOW_INSECURE_AS4_PLACEHOLDERS must be "true" or "false"',
},
GLEIF_API_BASE_URL: {
required: false,
description: 'Base URL for the LEI registry API used during participant onboarding',
validator: (value) => {
try {
new URL(value);
return true;
} catch {
return false;
}
},
errorMessage: 'GLEIF_API_BASE_URL must be a valid URL',
},
DBIS_REQUIRE_LEI_REGISTRY_VALIDATION: {
required: false,
description: 'Require registry-backed LEI validation during institution registration',
validator: (value) => value === 'true' || value === 'false',
errorMessage: 'DBIS_REQUIRE_LEI_REGISTRY_VALIDATION must be "true" or "false"',
},
DBIS_ALLOW_LEI_FORMAT_ONLY_FALLBACK: {
required: false,
description: 'Allow LEI format-only acceptance when registry lookup is unavailable',
validator: (value) => value === 'true' || value === 'false',
errorMessage: 'DBIS_ALLOW_LEI_FORMAT_ONLY_FALLBACK must be "true" or "false"',
},
DBIS_LEI_LOOKUP_TIMEOUT_MS: {
required: false,
description: 'HTTP timeout for LEI registry lookups in milliseconds',
validator: (value) => /^\d+$/.test(value) && Number(value) >= 1000,
errorMessage: 'DBIS_LEI_LOOKUP_TIMEOUT_MS must be a number greater than or equal to 1000',
},
}; };
/** /**
@@ -293,4 +415,3 @@ export function getEnv(key: string, defaultValue?: string): string {
} }
return value || defaultValue || ''; return value || defaultValue || '';
} }

View File

@@ -527,8 +527,15 @@ export interface RequestSignature {
} }
export interface JwtPayload { export interface JwtPayload {
sovereignBankId: string; sovereignBankId?: string;
identityType: string; identityType: string;
employeeId?: string;
email?: string;
name?: string;
roleName?: string;
permissions?: string[];
sessionType?: 'portal' | 'service';
portalSurface?: 'admin' | 'member' | 'core';
apiRole?: string; apiRole?: string;
iat?: number; iat?: number;
exp?: number; exp?: number;
@@ -696,4 +703,3 @@ export class DbisError extends Error {
this.name = 'DbisError'; this.name = 'DbisError';
} }
} }

View File

@@ -6,6 +6,13 @@ declare global {
namespace Express { namespace Express {
interface Request { interface Request {
sovereignBankId?: string; sovereignBankId?: string;
employeeId?: string;
email?: string;
name?: string;
roleName?: string;
permissions?: string[];
sessionType?: 'portal' | 'service';
portalSurface?: 'admin' | 'member' | 'core';
identityType?: string; identityType?: string;
apiRole?: string; apiRole?: string;
} }

View File

@@ -15,6 +15,7 @@ server {
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always; add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always; add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; upgrade-insecure-requests" always;
# SPA routing # SPA routing
location / { location / {
@@ -23,7 +24,7 @@ server {
# API proxy (optional - if frontend needs to proxy API requests) # API proxy (optional - if frontend needs to proxy API requests)
location /api { location /api {
proxy_pass http://192.168.11.150:3000; proxy_pass http://192.168.11.155:3000;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -46,4 +47,3 @@ server {
add_header Content-Type text/plain; add_header Content-Type text/plain;
} }
} }