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
**⚠️ 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:
- **Any username and password will work**
- 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"]
}
```
There are now two supported login patterns:
- `core.d-bis.org` and `admin.d-bis.org` use employee-backed portal auth.
- `secure.d-bis.org` uses member-backed portal auth.
### Login Instructions
1. **Go to:** http://192.168.11.130/login
2. **Enter any username** (e.g., `admin`, `test`, `user`)
3. **Enter any password** (e.g., `password`, `123456`, `admin`)
4. **Click "Sign In"**
1. Go to the portal surface you need:
- `https://core.d-bis.org/login`
- `https://admin.d-bis.org/login`
- `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
@@ -78,7 +83,7 @@ When you log in with any credentials, you'll receive:
---
## 🔌 Backend API Endpoints
## Backend API Endpoints
### Base URL
@@ -90,9 +95,23 @@ When you log in with any credentials, you'll receive:
| Method | Endpoint | Description | Status |
|--------|----------|-------------|--------|
| `POST` | `/api/auth/login` | User login | ⚠️ Not implemented (using mock) |
| `POST` | `/api/auth/logout` | User logout | ⚠️ Not implemented (using mock) |
| `POST` | `/api/auth/refresh` | Refresh token | ⚠️ Not implemented |
| `POST` | `/api/auth/login` | Portal login | Live |
| `POST` | `/api/auth/logout` | Portal logout | Live |
| `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
@@ -203,10 +222,13 @@ When you log in with any credentials, you'll receive:
### Current Implementation
- **Type:** Mock authentication (development mode)
- **Type:** Live backend-backed portal authentication
- **Token Storage:** `sessionStorage` (cleared on tab close)
- **Token Format:** `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
@@ -230,16 +252,18 @@ Content-Type: application/json
---
## 📍 Quick Reference
## Quick Reference
### Login
- **URL:** http://192.168.11.130/login
- **Credentials:** Any username/password combination
- **After Login:** Redirects to `/dbis/overview`
- **Core:** `https://core.d-bis.org/login`
- **Admin:** `https://admin.d-bis.org/login`
- **Member:** `https://secure.d-bis.org/login`
- **After Login:** Redirects to the runtime portal home route
### Main Dashboards
- **DBIS Overview:** http://192.168.11.130/dbis/overview
- **SCB Overview:** http://192.168.11.130/scb/overview
- **Core Overview:** `https://core.d-bis.org/`
- **Admin Overview:** `https://admin.d-bis.org/`
- **Member Overview:** `https://secure.d-bis.org/`
### API Base URL
- **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
2. **Backend Required:** Most API endpoints require a running backend
3. **Token Format:** Uses `SOV-TOKEN` prefix (not standard `Bearer`)
4. **Session Storage:** Tokens stored in `sessionStorage` (not `localStorage`)
5. **Auto-Logout:** Session clears when browser tab closes
1. **Real portal auth:** The frontend calls the backend auth routes and no longer accepts arbitrary credentials.
2. **Backend required:** Portal login depends on the live DBIS API.
3. **Token format:** Portal sessions use JWT bearer tokens.
4. **Session storage:** Tokens and user state are kept in `sessionStorage`.
5. **Member surface:** `secure.d-bis.org` uses the member shared-secret login path.
---
## 🔄 Next Steps
## Next Steps
To enable real authentication:
1. Implement backend `/api/auth/login` endpoint
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.
1. Replace shared-secret employee bootstrap access with individually managed credentials only.
2. Add token refresh or httpOnly cookie sessions.
3. Add role-specific operator runbooks for issuing portal accounts.

View File

@@ -4,11 +4,10 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DBIS Admin Console</title>
<title>DBIS Institutional Portal</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</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 { useAuthStore } from './stores/authStore';
import ProtectedRoute from './components/auth/ProtectedRoute';
@@ -6,10 +6,12 @@ import ErrorBoundary from './components/shared/ErrorBoundary';
import PageError from './components/shared/PageError';
import LoadingSpinner from './components/shared/LoadingSpinner';
import SkipLink from './components/shared/SkipLink';
import { runtimePortal } from './config/runtimePortal';
// Layout components (loaded immediately as they're always needed)
import DBISLayout from './components/layout/DBISLayout';
import SCBLayout from './components/layout/SCBLayout';
import MemberPortalLayout from './components/layout/MemberPortalLayout';
// Auth page (loaded immediately for faster login)
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 SCBFIManagementPage = lazy(() => import('./pages/scb/FIManagementPage'));
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
@@ -39,102 +46,158 @@ const LazyRoute = ({ children }: { children: React.ReactNode }) => (
function App() {
const { isAuthenticated } = useAuthStore();
useEffect(() => {
document.title = runtimePortal.pageTitle;
}, []);
return (
<ErrorBoundary>
<SkipLink />
<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 path="/dbis/*" element={<DBISLayout />}>
<Route
path="overview"
element={
<LazyRoute>
<DBISOverviewPage />
</LazyRoute>
}
/>
<Route
path="participants"
element={
<LazyRoute>
<DBISParticipantsPage />
</LazyRoute>
}
/>
<Route
path="gru"
element={
<LazyRoute>
<DBISGRUPage />
</LazyRoute>
}
/>
<Route
path="gas-qps"
element={
<LazyRoute>
<DBISGASQPSPage />
</LazyRoute>
}
/>
<Route
path="cbdc-fx"
element={
<LazyRoute>
<DBISCBDCFXPage />
</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 />} />
{runtimePortal.isMember ? (
<>
<Route path="/portal/*" element={<MemberPortalLayout />}>
<Route
path="overview"
element={
<LazyRoute>
<MemberOverviewPage />
</LazyRoute>
}
/>
<Route
path="access"
element={
<LazyRoute>
<MemberAccessPage />
</LazyRoute>
}
/>
<Route
path="documents"
element={
<LazyRoute>
<MemberDocumentsPage />
</LazyRoute>
}
/>
<Route
path="status"
element={
<LazyRoute>
<MemberStatusPage />
</LazyRoute>
}
/>
<Route
path="support"
element={
<LazyRoute>
<MemberSupportPage />
</LazyRoute>
}
/>
<Route index element={<Navigate to="overview" replace />} />
</Route>
<Route path="/" element={<Navigate to="/portal/overview" replace />} />
</>
) : (
<>
<Route path="/dbis/*" element={<DBISLayout />}>
<Route
path="overview"
element={
<LazyRoute>
<DBISOverviewPage />
</LazyRoute>
}
/>
<Route
path="participants"
element={
<LazyRoute>
<DBISParticipantsPage />
</LazyRoute>
}
/>
<Route
path="gru"
element={
<LazyRoute>
<DBISGRUPage />
</LazyRoute>
}
/>
<Route
path="gas-qps"
element={
<LazyRoute>
<DBISGASQPSPage />
</LazyRoute>
}
/>
<Route
path="cbdc-fx"
element={
<LazyRoute>
<DBISCBDCFXPage />
</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 path="/404" element={<PageError code={404} />} />
@@ -147,4 +210,3 @@ function App() {
}
export default App;

View File

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

View File

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

View File

@@ -11,6 +11,31 @@
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 {
font-size: 1.25rem;
font-weight: 700;
@@ -42,3 +67,12 @@
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 { useNavigate } from 'react-router-dom';
import Button from '@/components/shared/Button';
import { runtimePortal } from '@/config/runtimePortal';
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 navigate = useNavigate();
@@ -16,7 +22,18 @@ export default function TopBar() {
return (
<header className="topbar">
<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 className="topbar__right">
<div className="topbar__user">
@@ -30,4 +47,3 @@ export default function TopBar() {
</header>
);
}

View File

@@ -6,6 +6,19 @@
*/
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({
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'),
@@ -21,7 +34,7 @@ let env: Env;
try {
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_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 { useAuthStore } from './stores/authStore';
import { env } from './config/env';
import { runtimePortal } from './config/runtimePortal';
import { logger } from './utils/logger';
import { errorTracker } from './utils/errorTracking';
import './index.css';
@@ -13,7 +14,7 @@ import './index.css';
// Initialize error tracking
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({
defaultOptions: {

View File

@@ -4,13 +4,16 @@ import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '@/stores/authStore';
import Button from '@/components/shared/Button';
import toast from 'react-hot-toast';
import { runtimePortal } from '@/config/runtimePortal';
import './LoginPage.css';
export default function LoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [otp, setOtp] = useState('');
const [rememberMe, setRememberMe] = useState(false);
const [loading, setLoading] = useState(false);
const [mfaRequired, setMfaRequired] = useState(false);
const { login } = useAuthStore();
const navigate = useNavigate();
@@ -19,9 +22,18 @@ export default function LoginPage() {
setLoading(true);
try {
await login({ username, password, rememberMe });
toast.success('Login successful');
navigate('/dbis/overview');
const result = await login({ username, password, otp, rememberMe });
if (result.mfaRequired) {
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) {
toast.error(error.message || 'Login failed');
} finally {
@@ -33,8 +45,8 @@ export default function LoginPage() {
<div className="login-page">
<div className="login-page__container">
<div className="login-page__header">
<h1>DBIS Admin Console</h1>
<p>Sign in to your account</p>
<h1>{runtimePortal.loginTitle}</h1>
<p>{runtimePortal.loginSubtitle}</p>
</div>
<form onSubmit={handleSubmit} className="login-page__form">
<div className="login-page__field">
@@ -59,6 +71,22 @@ export default function LoginPage() {
autoComplete="current-password"
/>
</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">
<label className="login-page__checkbox">
<input
@@ -70,11 +98,10 @@ export default function LoginPage() {
</label>
</div>
<Button type="submit" loading={loading} fullWidth>
Sign In
{mfaRequired ? 'Verify Code' : 'Sign In'}
</Button>
</form>
</div>
</div>
);
}

View File

@@ -8,6 +8,7 @@ import DataTable, { Column } from '@/components/shared/DataTable';
import Button from '@/components/shared/Button';
import LoadingSpinner from '@/components/shared/LoadingSpinner';
import type { SCBStatus } from '@/types';
import { env } from '@/config/env';
import './OverviewPage.css';
export default function OverviewPage() {
@@ -38,7 +39,7 @@ export default function OverviewPage() {
{isNetworkError ? (
<div>
<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>
<Button
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) => {
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);
if (axios.isCancel(error)) {
@@ -101,10 +105,13 @@ class ApiClient {
switch (status) {
case 401:
sessionStorage.removeItem('auth_token');
sessionStorage.removeItem('user');
window.location.href = '/login';
toast.error(ERROR_MESSAGES.UNAUTHORIZED);
if (!isInteractiveAuthFlow) {
sessionStorage.removeItem('auth_token');
sessionStorage.removeItem('user');
sessionStorage.removeItem('auth-storage');
window.location.href = '/login';
toast.error(ERROR_MESSAGES.UNAUTHORIZED);
}
break;
case 403:
toast.error(ERROR_MESSAGES.FORBIDDEN);

View File

@@ -1,6 +1,7 @@
// Authentication Service
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
@@ -19,33 +20,39 @@ class AuthService {
// Tokens are cleared when the browser tab/window is closed
private readonly storage = sessionStorage;
async login(credentials: LoginCredentials): Promise<{ user: User; token: string }> {
// TODO: Replace with actual login endpoint when available
// For now, this is a placeholder that would call the backend
// const response = await apiClient.post('/api/auth/login', credentials);
// Mock response for development
const mockUser: User = {
id: '1',
employeeId: 'emp-001',
name: 'Admin User',
email: credentials.username,
role: 'DBIS_Super_Admin',
permissions: ['all'],
};
private decodeTokenPayload(token: string): { exp?: number } | null {
try {
return JSON.parse(atob(token.split('.')[1]));
} catch {
return null;
}
}
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);
this.setUser(mockUser);
if (!response.mfaRequired) {
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> {
try {
// Call logout endpoint if available
// await apiClient.post('/api/auth/logout');
await apiClient.post('/api/auth/logout');
} catch (error) {
// Ignore errors on logout
} 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 {
try {
return this.storage.getItem(this.TOKEN_KEY);
@@ -105,13 +123,12 @@ class AuthService {
if (!token) return false;
// Check if token is expired (basic check)
try {
const payload = JSON.parse(atob(token.split('.')[1]));
const exp = payload.exp * 1000; // Convert to milliseconds
return Date.now() < exp;
} catch {
const payload = this.decodeTokenPayload(token);
if (!payload?.exp) {
return false;
}
const exp = payload.exp * 1000; // Convert to milliseconds
return Date.now() < exp;
}
async refreshToken(): Promise<string | null> {
@@ -129,4 +146,3 @@ class AuthService {
}
export const authService = new AuthService();

View File

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

View File

@@ -20,9 +20,32 @@ export interface AuthState {
export interface LoginCredentials {
username: string;
password: string;
otp?: string;
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 {
code: string;
message: string;
@@ -114,4 +137,3 @@ export interface ParticipantInfo {
latency?: number;
errorRate?: number;
}

112
package-lock.json generated
View File

@@ -31,6 +31,7 @@
"jsonwebtoken": "^9.0.2",
"kafkajs": "^2.2.4",
"nodemailer": "^7.0.13",
"redis": "^4.7.1",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.0",
"uuid": "^9.0.1",
@@ -754,7 +755,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -2198,6 +2198,71 @@
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
"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": {
"version": "1.4.0",
"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",
"integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -3287,7 +3351,6 @@
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0",
@@ -3470,7 +3533,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3928,7 +3990,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754",
@@ -4251,6 +4312,15 @@
"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": {
"version": "4.6.0",
"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.",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -5216,7 +5285,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -5626,6 +5694,15 @@
"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": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -6262,7 +6339,6 @@
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "^29.7.0",
"@jest/types": "^29.6.3",
@@ -8164,7 +8240,6 @@
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@prisma/engines": "5.22.0"
},
@@ -8362,6 +8437,23 @@
"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": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -9411,7 +9503,6 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@@ -9618,7 +9709,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"

View File

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

View File

@@ -2442,6 +2442,19 @@ model employee_credentials {
employeeName String
email 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?
hsmCredentialId String?
status String @default("active")
@@ -2458,6 +2471,44 @@ model employee_credentials {
@@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 {
id String @id
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
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
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 "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"

View File

@@ -2,40 +2,65 @@
import request from 'supertest';
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('zeroTrustAuthMiddleware', () => {
it('should reject requests without token', async () => {
const response = await request(app).get('/api/health').expect(401);
it('rejects requests without a token', async () => {
const response = await request(app).get('/api/auth/me').expect(401);
expect(response.body.success).toBe(false);
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)
.get('/api/health')
.get('/api/auth/me')
.set('authorization', 'SOV-TOKEN invalid-token')
.expect(401);
expect(response.body.success).toBe(false);
expect(response.body.error.code).toBe('UNAUTHORIZED');
});
it('should accept requests with valid token', async () => {
// Note: This test may need adjustment based on actual route implementation
// Health endpoint should not require auth
const response = await request(app).get('/health').expect(200);
it('accepts a valid portal token without sovereign signature headers', async () => {
const token = createTestToken({
employeeId: 'EMP-001',
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', () => {
it('should require signature headers', async () => {
const token = createTestToken({ sovereignBankId: 'test-bank' });
it('requires signature headers for service tokens', async () => {
const token = createTestToken({
sovereignBankId: 'test-bank',
identityType: 'API',
sessionType: 'service',
});
const response = await request(app)
.get('/api/health')
.get('/api/auth/me')
.set('authorization', `SOV-TOKEN ${token}`)
.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 {
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
*/
@@ -133,41 +144,25 @@ export class GlobalOverviewService {
subsystems.push({ subsystem: 'GAS', status: 'down' });
}
// QPS (Quantum Payment System) - placeholder
subsystems.push({
subsystem: 'QPS',
status: 'healthy',
});
// Ω-Layer (Omega Layer) - placeholder
subsystems.push({
subsystem: 'Ω-Layer',
status: 'healthy',
});
// GPN (Global Payment Network) - placeholder
subsystems.push({
subsystem: 'GPN',
status: 'healthy',
});
// 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',
});
if (this.allowPlaceholderMetrics) {
subsystems.push(
{ subsystem: 'QPS', status: 'healthy' },
{ subsystem: 'Ω-Layer', status: 'healthy' },
{ subsystem: 'GPN', status: 'healthy' },
{ subsystem: 'GRU Engine', status: 'healthy' },
{ subsystem: 'Metaverse MEN', status: 'healthy' },
{ subsystem: '6G Edge Grid', status: 'healthy' }
);
} else {
subsystems.push(
{ subsystem: 'QPS', status: 'degraded' },
{ subsystem: 'Ω-Layer', status: 'degraded' },
{ subsystem: 'GPN', status: 'degraded' },
{ subsystem: 'GRU Engine', status: 'degraded' },
{ subsystem: 'Metaverse MEN', status: 'degraded' },
{ subsystem: '6G Edge Grid', status: 'degraded' }
);
}
return subsystems;
}
@@ -176,188 +171,237 @@ export class GlobalOverviewService {
* Get settlement throughput metrics
*/
async getSettlementThroughput(): Promise<SettlementThroughput> {
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const oneMinuteAgo = new Date(Date.now() - 60 * 1000);
try {
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({
where: {
createdAt: {
gte: oneDayAgo,
where: {
createdAt: {
gte: oneDayAgo,
},
},
},
});
});
// Get settlements in last minute for tx/sec
const recentSettlements = settlements.filter(
(s: any) => s.createdAt >= oneMinuteAgo
);
const recentSettlements = settlements.filter(
(s: any) => s.createdAt >= oneMinuteAgo
);
const txPerSecond = recentSettlements.length / 60;
// Calculate daily volume
const dailyVolume = settlements
const txPerSecond = recentSettlements.length / 60;
const dailyVolume = settlements
.filter((s: any) => s.status === 'settled')
.reduce((sum: Decimal, s: { amount?: unknown }) => sum.plus(Number(s.amount ?? 0)), new Decimal(0))
.toNumber();
.toNumber();
// Group by asset type
const byAssetType = {
fiat: 0,
cbdc: 0,
gru: 0,
ssu: 0,
commodities: 0,
};
const byAssetType = {
fiat: 0,
cbdc: 0,
gru: 0,
ssu: 0,
commodities: 0,
};
settlements.forEach((s: any) => {
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 === 'commodity') byAssetType.commodities += parseFloat(s.amount.toString());
// GRU and SSU would need additional queries
});
settlements.forEach((s: any) => {
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 === 'commodity') byAssetType.commodities += parseFloat(s.amount.toString());
});
// Heatmap: top corridors by volume
const corridorMap = new Map<string, number>();
settlements.forEach((s: any) => {
if (s.status === 'settled') {
const key = `${s.sourceBankId}-${s.destinationBankId}`;
const current = corridorMap.get(key) || 0;
corridorMap.set(key, current + parseFloat(s.amount.toString()));
}
});
const corridorMap = new Map<string, number>();
settlements.forEach((s: any) => {
if (s.status === 'settled') {
const key = `${s.sourceBankId}-${s.destinationBankId}`;
const current = corridorMap.get(key) || 0;
corridorMap.set(key, current + parseFloat(s.amount.toString()));
}
});
const heatmap = Array.from(corridorMap.entries())
.map(([key, volume]) => {
const [source, dest] = key.split('-');
return { sourceSCB: source, destinationSCB: dest, volume };
})
.sort((a, b) => b.volume - a.volume)
.slice(0, 20); // Top 20
const heatmap = Array.from(corridorMap.entries())
.map(([key, volume]) => {
const [source, dest] = key.split('-');
return { sourceSCB: source, destinationSCB: dest, volume };
})
.sort((a, b) => b.volume - a.volume)
.slice(0, 20);
return {
txPerSecond,
dailyVolume,
byAssetType,
heatmap,
};
return {
txPerSecond,
dailyVolume,
byAssetType,
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
*/
async getGRULiquidity(): Promise<GRULiquidityMetrics> {
// Get GRU units
const gruUnits = await prisma.gru_units.findMany({
where: { status: 'active' },
});
try {
await prisma.gru_units.findMany({
where: { status: 'active' },
});
// Calculate in circulation by class
const inCirculation = {
m00: 0,
m0: 0,
m1: 0,
sr1: 0,
sr2: 0,
sr3: 0,
};
const inCirculation = {
m00: 0,
m0: 0,
m1: 0,
sr1: 0,
sr2: 0,
sr3: 0,
};
// Get GRU indexes for price
const indexes = await prisma.gru_indexes.findMany({
where: { status: 'active' },
include: { gru_index_price_history: { orderBy: { timestamp: 'desc' }, take: 2 } },
});
const indexes = await prisma.gru_indexes.findMany({
where: { status: 'active' },
include: { gru_index_price_history: { orderBy: { timestamp: 'desc' }, take: 2 } },
});
let currentPrice = 1.0; // Default
let volatility = 0.0;
let currentPrice = 1.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 priceHistory = (indexes[0] as { gru_index_price_history: Array<{ indexValue?: unknown }> }).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;
const latestIndex = indexes[0] as
| { gru_index_price_history?: Array<{ indexValue?: unknown }> }
| undefined;
if (latestIndex?.gru_index_price_history && latestIndex.gru_index_price_history.length >= 2) {
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
*/
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 || [];
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;
return {
high,
medium,
low,
alerts: alerts.slice(0, 10).map((a: any, idx: number) => ({
id: a.id || `alert-${idx}`,
type: a.type || 'unknown',
severity: a.severity || 'low',
description: a.description || '',
timestamp: a.timestamp || new Date(),
})),
};
return {
high,
medium,
low,
alerts: alerts.slice(0, 10).map((a: any, idx: number) => ({
id: a.id || `alert-${idx}`,
type: a.type || 'unknown',
severity: a.severity || 'low',
description: a.description || '',
timestamp: a.timestamp || new Date(),
})),
};
} catch (error) {
logger.warn('Risk flags unavailable, returning fallback dashboard values', {
missingTable: this.isMissingTableError(error),
error: error instanceof Error ? error.message : String(error),
});
return {
high: 0,
medium: 0,
low: 0,
alerts: [],
};
}
}
/**
* Get SCB status table
*/
async getSCBStatus(): Promise<SCBStatus[]> {
const scbs = await prisma.sovereign_banks.findMany({
where: { status: { in: ['active', 'suspended'] } },
});
try {
const scbs = await prisma.sovereign_banks.findMany({
where: { status: { in: ['active', 'suspended'] } },
});
const scbStatus: SCBStatus[] = [];
const scbStatus: SCBStatus[] = [];
for (const scb of scbs) {
// Get recent settlements to determine connectivity
const recentSettlements = await prisma.atomic_settlements.findMany({
where: {
OR: [{ sourceBankId: scb.id }, { destinationBankId: scb.id }],
createdAt: {
gte: new Date(Date.now() - 5 * 60 * 1000), // Last 5 minutes
for (const scb of scbs) {
const recentSettlements = await prisma.atomic_settlements.findMany({
where: {
OR: [{ sourceBankId: scb.id }, { destinationBankId: scb.id }],
createdAt: {
gte: new Date(Date.now() - 5 * 60 * 1000),
},
},
},
take: 10,
});
take: 10,
});
const connectivity =
recentSettlements.length > 0 ? 'connected' : 'degraded';
const connectivity =
recentSettlements.length > 0 ? 'connected' : 'degraded';
// Get open incidents (SRI enforcements)
const openIncidents = await prisma.sri_enforcements.count({
where: {
sovereignBankId: scb.id,
status: 'active',
},
});
const openIncidents = await prisma.sri_enforcements.count({
where: {
sovereignBankId: scb.id,
status: 'active',
},
});
scbStatus.push({
scbId: scb.id,
name: scb.name,
country: scb.sovereignCode,
bic: scb.bic || undefined,
status: scb.status,
connectivity,
openIncidents,
scbStatus.push({
scbId: scb.id,
name: scb.name,
country: scb.sovereignCode,
bic: scb.bic || undefined,
status: scb.status,
connectivity,
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();

View File

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

View File

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

View File

@@ -52,7 +52,7 @@ router.post(
if (!scbId) {
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(
employeeId,
scbId,
@@ -74,7 +74,7 @@ router.post(
if (!scbId) {
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);
return res.json(result);
} catch (error) {
@@ -92,7 +92,7 @@ router.post(
if (!scbId) {
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(
employeeId,
scbId,
@@ -133,7 +133,7 @@ router.post(
if (!scbId) {
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(
employeeId,
scbId,
@@ -155,7 +155,7 @@ router.post(
if (!scbId) {
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(
employeeId,
scbId,
@@ -169,4 +169,3 @@ router.post(
);
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 { 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 { reconciliationService } from './reconciliation.service';
import { webhookService } from './webhook.service';
@@ -177,7 +181,7 @@ router.get('/participants/:participantId', zeroTrustAuthMiddleware, async (req,
* example:
* name: "Central Bank of Example"
* bic: "CBEXUS33"
* lei: "5493000X9ZXSQ9B6Y815"
* lei: "5493001KJTIIGC8Y1R35"
* country: "US"
* regulatoryTier: "SCB"
* responses:
@@ -197,19 +201,24 @@ router.get('/participants/:participantId', zeroTrustAuthMiddleware, async (req,
* 401:
* $ref: '#/components/responses/Unauthorized'
*/
router.post('/participants', zeroTrustAuthMiddleware, async (req, res, next) => {
try {
const participant = await nostroVostroService.createParticipant(req.body);
router.post(
'/participants',
zeroTrustAuthMiddleware,
validateRequest({ body: nostroVostroValidationSchemas.participantCreateRequest }),
async (req, res, next) => {
try {
const participant = await nostroVostroService.createParticipant(req.body);
res.status(201).json({
success: true,
data: participant,
timestamp: new Date(),
});
} catch (error) {
res.status(201).json({
success: true,
data: participant,
timestamp: new Date(),
});
} catch (error) {
return next(error);
}
}
});
);
// ============================================================================
// Account Endpoints
@@ -858,4 +867,3 @@ router.post('/webhooks/events', async (req, res, next) => {
});
export default router;

View File

@@ -17,6 +17,7 @@ import {
TransferStatus,
SettlementAsset,
} from './nostro-vostro.types';
import { leiValidationService } from './lei-validation.service';
export class NostroVostroService {
// ============================================================================
@@ -27,7 +28,15 @@ export class NostroVostroService {
* Create a new 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
const existing = await prisma.nostro_vostro_participants.findUnique({
@@ -39,36 +48,40 @@ export class NostroVostroService {
}
// Check BIC uniqueness if provided
if (request.bic) {
if (normalizedBic) {
const existingBic = await prisma.nostro_vostro_participants.findUnique({
where: { bic: request.bic },
where: { bic: normalizedBic },
});
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
if (request.lei) {
const existingLei = await prisma.nostro_vostro_participants.findUnique({
where: { lei: request.lei },
});
if (existingLei) {
throw new DbisError(ErrorCode.VALIDATION_ERROR, `Participant with LEI ${request.lei} already exists`);
}
const existingLei = await prisma.nostro_vostro_participants.findUnique({
where: { lei: normalizedLei },
});
if (existingLei) {
throw new DbisError(ErrorCode.VALIDATION_ERROR, `Participant with LEI ${normalizedLei} already exists`);
}
const metadata = {
...(request.metadata || {}),
leiValidation,
} as unknown as Prisma.InputJsonValue;
const participant = await prisma.nostro_vostro_participants.create({
data: {
id: uuidv4(),
participantId,
name: request.name,
bic: request.bic,
lei: request.lei,
country: request.country,
bic: normalizedBic,
lei: normalizedLei,
country: normalizedCountry,
regulatoryTier: request.regulatoryTier,
sovereignBankId: request.sovereignBankId,
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({
data: {
id: uuidv4(),
accountId,
ownerParticipantId: request.ownerParticipantId,
counterpartyParticipantId: request.counterpartyParticipantId,
@@ -173,7 +187,8 @@ export class NostroVostroService {
currentBalance: new Decimal(0),
availableLiquidity: 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();

View File

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

View File

@@ -109,9 +109,9 @@ export class RbacEngineService {
participant: ParticipantType;
accessLevel: string;
} | null> {
const employee = await prisma.employeeCredential.findUnique({
const employee = await prisma.employee_credentials.findUnique({
where: { employeeId },
include: { role: true },
include: { dbis_roles: true },
});
if (!employee || employee.status !== 'active') {
@@ -119,7 +119,7 @@ export class RbacEngineService {
}
// Determine participant type from role name
const roleName = employee.role.roleName;
const roleName = employee.dbis_roles.roleName;
let participant: ParticipantType = 'DBIS';
if (roleName.startsWith('SCB_')) {
@@ -131,7 +131,7 @@ export class RbacEngineService {
return {
roleName,
participant,
accessLevel: employee.role.accessLevel,
accessLevel: employee.dbis_roles.accessLevel,
};
}
@@ -421,7 +421,8 @@ export class RbacEngineService {
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();

View File

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

View File

@@ -23,6 +23,16 @@ export interface MessageEncryption {
}
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
*/
@@ -99,8 +109,10 @@ export class As4SecurityService {
throw new Error(`Member ${memberId} not found or no signing certificate`);
}
this.ensureSecureImplementationEnabled('signing');
// 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');
sign.update(messagePayload);
sign.end();
@@ -144,8 +156,10 @@ export class As4SecurityService {
return false;
}
this.ensureSecureImplementationEnabled('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')
.update(messagePayload + member.signingCertFingerprint)
.digest('hex');
@@ -167,8 +181,10 @@ export class As4SecurityService {
throw new Error(`Recipient ${recipientMemberId} not found or no encryption certificate`);
}
this.ensureSecureImplementationEnabled('encryption');
// 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');
return {
@@ -200,8 +216,10 @@ export class As4SecurityService {
throw new Error('Certificate fingerprint mismatch');
}
this.ensureSecureImplementationEnabled('decryption');
// 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');
}

View File

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

View File

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

View File

@@ -11,6 +11,8 @@ export interface ProxmoxConfig {
username: string;
password: string;
realm?: string;
tokenName?: string;
tokenValue?: string;
}
export interface ContainerSpec {
@@ -57,6 +59,17 @@ export class ProxmoxVEIntegration {
* Authenticate with Proxmox VE
*/
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 () => {
return await retryWithBackoff(
async () => {
@@ -240,7 +253,9 @@ export class ProxmoxVEIntegration {
const options: RequestInit = {
method,
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
function getProxmoxConfig(): ProxmoxConfig {
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 tokenName = process.env.PROXMOX_TOKEN_NAME;
const tokenValue = process.env.PROXMOX_TOKEN_VALUE;
if (process.env.NODE_ENV === 'production') {
if (!host) {
throw new Error('PROXMOX_HOST environment variable is required in production');
}
if (!username) {
throw new Error('PROXMOX_USERNAME environment variable is required in production');
}
if (!password) {
throw new Error('PROXMOX_PASSWORD environment variable is required in production');
}
const hasPasswordAuth = Boolean(host && username && password);
const hasTokenAuth = Boolean(host && username && tokenName && tokenValue);
if (process.env.NODE_ENV === 'production' && !hasPasswordAuth && !hasTokenAuth) {
logger.warn('Proxmox integration environment is incomplete; IRU deployment features will remain unavailable', {
hasHost: Boolean(host),
hasUsername: Boolean(username),
hasPassword: Boolean(password),
hasTokenName: Boolean(tokenName),
hasTokenValue: Boolean(tokenValue),
});
}
return {
@@ -302,6 +320,8 @@ function getProxmoxConfig(): ProxmoxConfig {
username: username || 'root',
password: password || '',
realm: process.env.PROXMOX_REALM || 'pam',
tokenName,
tokenValue,
};
}

View File

@@ -1,28 +1,18 @@
// Express.js API Gateway Application
import '@/bootstrap/env';
import express, { Express } from 'express';
import cors from 'cors';
import helmet from 'helmet';
import swaggerUi from 'swagger-ui-express';
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 { errorHandler } from './middleware/error.middleware';
import { auditLogMiddleware } from './middleware/audit.middleware';
import { validateEnvironment } from '@/shared/config/env-validator';
import { logger } from '@/infrastructure/monitoring/logger';
import { tracingMiddleware } from '@/infrastructure/monitoring/tracing.middleware';
// 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 authRoutes from './routes/auth.routes';
// Import route handlers (will be created)
// import paymentRoutes from '@/core/payments/payment.routes';
@@ -285,6 +275,9 @@ app.get(['/health', '/v1/health'], async (req, res) => {
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)
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';
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)
// All API routes require authentication
app.use('/api', zeroTrustAuthMiddleware);
app.use('/api', dynamicRateLimitMiddleware);
@@ -365,11 +361,9 @@ app.use('/api/v1/routes', tezosUsdtzRoutes);
import as4GatewayRoutes from '@/core/settlement/as4/as4.routes';
import as4MemberDirectoryRoutes from '@/core/settlement/as4-settlement/member-directory/member-directory.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/directory', as4MemberDirectoryRoutes);
app.use('/api/v1/as4/settlement', as4SettlementRoutes);
app.use('/api/v1/as4', as4MetricsRoutes); // Metrics endpoint (public for Prometheus)
// Volume V routes
app.use('/api/v1/gbig', gbigRoutes);

View File

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

View File

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

View File

@@ -10,10 +10,21 @@ import { getEnv } from '@/shared/config/env-validator';
export interface AuthenticatedRequest extends Request {
sovereignBankId?: string;
employeeId?: string;
email?: string;
name?: string;
roleName?: string;
permissions?: string[];
sessionType?: 'portal' | 'service';
portalSurface?: 'admin' | 'member' | 'core';
identityType?: string;
apiRole?: string;
}
function isPortalSession(payload: JwtPayload): boolean {
return payload.sessionType === 'portal' || payload.identityType === 'WEB_PORTAL';
}
/**
* 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');
}
// Extract sovereign bank ID and identity type
// Extract claims shared by service and portal sessions
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.apiRole = decoded.apiRole;
if (!req.sovereignBankId) {
throw new DbisError(ErrorCode.UNAUTHORIZED, 'Invalid token payload');
}
if (isPortalSession(decoded)) {
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
const signatureValid = await verifyRequestSignature(
req,
req.sovereignBankId,
req.identityType || ''
);
if (!signatureValid) {
throw new DbisError(ErrorCode.UNAUTHORIZED, 'Invalid request signature');
// Verify request signature
const signatureValid = await verifyRequestSignature(
req,
req.sovereignBankId,
req.identityType || ''
);
if (!signatureValid) {
throw new DbisError(ErrorCode.UNAUTHORIZED, 'Invalid request signature');
}
}
// Check token expiration
@@ -207,6 +238,20 @@ export function optionalAuthMiddleware(
if (jwtSecret) {
const decoded = jwt.verify(token, jwtSecret) as JwtPayload;
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.apiRole = decoded.apiRole;
}
@@ -219,4 +264,3 @@ export function optionalAuthMiddleware(
}
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
*/
@@ -48,7 +54,7 @@ export function dynamicRateLimitMiddleware(
tier = 'TIER_2';
}
const limiter = createRateLimiter(tier);
const limiter = tierLimiters[tier];
limiter(req, res, next);
}

View File

@@ -128,3 +128,94 @@ export const iruValidationSchemas = {
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;
if (keyType === 'ECC-521') {
const { publicKey: pubKey } = crypto.generateKeyPairSync('ec', {
const ecOptions: crypto.ECKeyPairKeyObjectOptions = {
namedCurve: 'secp521r1',
publicKeyEncoding: { type: 'spki', format: 'pem' },
});
publicKey = pubKey;
};
const { publicKey: pubKey } = crypto.generateKeyPairSync('ec', ecOptions);
publicKey = pubKey.export({ type: 'spki', format: 'pem' }).toString();
} else {
const { publicKey: pubKey } = crypto.generateKeyPairSync('rsa', {
const rsaOptions: crypto.RSAKeyPairKeyObjectOptions = {
modulusLength: 4096,
publicKeyEncoding: { type: 'spki', format: 'pem' },
});
publicKey = pubKey;
};
const { publicKey: pubKey } = crypto.generateKeyPairSync('rsa', rsaOptions);
publicKey = pubKey.export({ type: 'spki', format: 'pem' }).toString();
}
const key: HSMKey = {
@@ -180,4 +180,3 @@ export class HSMService {
}
export const hsmService = new HSMService();

View File

@@ -100,7 +100,7 @@ const envConfig: EnvConfig = {
// IRU - Proxmox VE Configuration
PROXMOX_HOST: {
required: process.env.NODE_ENV === 'production',
required: false,
description: 'Proxmox VE host address',
validator: (value) => {
try {
@@ -113,13 +113,13 @@ const envConfig: EnvConfig = {
errorMessage: 'PROXMOX_HOST must be a valid hostname or IP address',
},
PROXMOX_USERNAME: {
required: process.env.NODE_ENV === 'production',
required: false,
description: 'Proxmox VE username',
validator: (value) => value.length > 0,
errorMessage: 'PROXMOX_USERNAME cannot be empty',
},
PROXMOX_PASSWORD: {
required: process.env.NODE_ENV === 'production',
required: false,
description: 'Proxmox VE password',
validator: (value) => value.length >= 8,
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',
},
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 || '';
}

View File

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

View File

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

View File

@@ -15,6 +15,7 @@ server {
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" 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
location / {
@@ -23,7 +24,7 @@ server {
# API proxy (optional - if frontend needs to proxy API requests)
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 X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -46,4 +47,3 @@ server {
add_header Content-Type text/plain;
}
}