feat: add member portal and auth hardening
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
34
frontend/src/components/layout/MemberPortalLayout.tsx
Normal file
34
frontend/src/components/layout/MemberPortalLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
70
frontend/src/config/runtimePortal.ts
Normal file
70
frontend/src/config/runtimePortal.ts
Normal 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;
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
29
frontend/src/pages/portal/MemberAccessPage.tsx
Normal file
29
frontend/src/pages/portal/MemberAccessPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
frontend/src/pages/portal/MemberDocumentsPage.tsx
Normal file
36
frontend/src/pages/portal/MemberDocumentsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
frontend/src/pages/portal/MemberOverviewPage.tsx
Normal file
58
frontend/src/pages/portal/MemberOverviewPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
frontend/src/pages/portal/MemberStatusPage.tsx
Normal file
30
frontend/src/pages/portal/MemberStatusPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
frontend/src/pages/portal/MemberSupportPage.tsx
Normal file
22
frontend/src/pages/portal/MemberSupportPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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' }
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -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
112
package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
445
scripts/deployment/bootstrap-portal-auth.cjs
Normal file
445
scripts/deployment/bootstrap-portal-auth.cjs
Normal 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();
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
4
src/bootstrap/env.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
// Load process environment before any module performs validation or startup side effects.
|
||||
dotenv.config();
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
259
src/core/nostro-vostro/lei-validation.service.ts
Normal file
259
src/core/nostro-vostro/lei-validation.service.ts
Normal 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();
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
}),
|
||||
};
|
||||
|
||||
310
src/integration/api-gateway/routes/auth.routes.ts
Normal file
310
src/integration/api-gateway/routes/auth.routes.ts
Normal 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;
|
||||
1032
src/integration/api-gateway/services/portal-auth.service.ts
Normal file
1032
src/integration/api-gateway/services/portal-auth.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
171
src/integration/api-gateway/services/portal-security.service.ts
Normal file
171
src/integration/api-gateway/services/portal-security.service.ts
Normal 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();
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 || '';
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
7
src/types/express.d.ts
vendored
7
src/types/express.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user