iri-shield 1.2.3 → 1.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # iri-shield
2
2
 
3
- [![npm version](https://img.shields.io/badge/npm-v1.2.2-blue.svg)](https://www.npmjs.com/package/iri-shield)
3
+ [![npm version](https://img.shields.io/badge/npm-v1.2.4-blue.svg)](https://www.npmjs.com/package/iri-shield)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-emerald.svg)](https://opensource.org/licenses/MIT)
5
5
  [![Node.js](https://img.shields.io/badge/node-%3E%3D22.0.0-purple.svg)](https://nodejs.org)
6
6
  [![Security](https://img.shields.io/badge/security-risk--scoring-orange.svg)](#explainable-risk-scoring)
@@ -402,10 +402,13 @@ dashboard: {
402
402
  path: '/iri-shield',
403
403
  username: process.env.SHIELD_ADMIN_USER,
404
404
  password: process.env.SHIELD_ADMIN_PASSWORD,
405
+ sessionSecret: process.env.IRI_SHIELD_DASHBOARD_SECRET || process.env.SESSION_SECRET,
405
406
  refreshMs: 60 * 1000
406
407
  }
407
408
  ```
408
409
 
410
+ Set a stable `sessionSecret` on serverless hosts. Otherwise old deployments that used a random per-instance dashboard token could intermittently return `401 Unauthorized` from dashboard API calls after a cold start or instance switch.
411
+
409
412
  ### Production Configuration
410
413
 
411
414
  ```js
@@ -443,7 +446,8 @@ const shield = createShield({
443
446
  enabled: true,
444
447
  path: '/iri-shield',
445
448
  username: process.env.SHIELD_ADMIN_USER,
446
- password: process.env.SHIELD_ADMIN_PASSWORD
449
+ password: process.env.SHIELD_ADMIN_PASSWORD,
450
+ sessionSecret: process.env.IRI_SHIELD_DASHBOARD_SECRET || process.env.SESSION_SECRET || process.env.JWT_SECRET
447
451
  }
448
452
  });
449
453
 
@@ -476,6 +480,15 @@ storage: {
476
480
 
477
481
  This avoids filesystem initialization errors and is suitable for testing the middleware, dashboard, detection rules, and API behaviour.
478
482
 
483
+ For persistent Vercel dashboard history, use MongoDB:
484
+
485
+ ```js
486
+ storage: {
487
+ mode: 'mongodb',
488
+ mongoUrl: process.env.IRI_MONGO_URL
489
+ }
490
+ ```
491
+
479
492
  If you explicitly want SQLite for a short-lived demo, use `/tmp`:
480
493
 
481
494
  ```js
@@ -515,7 +528,7 @@ const shield = createShield({
515
528
  trustProxy: true,
516
529
 
517
530
  storage: {
518
- mode: process.env.IRI_STORAGE_MODE || 'memory',
531
+ mode: process.env.IRI_STORAGE_MODE || (process.env.IRI_MONGO_URL ? 'mongodb' : 'memory'),
519
532
  sqliteFile: process.env.IRI_SQLITE_FILE || '/tmp/iri-shield.sqlite',
520
533
  mongoUrl: process.env.IRI_MONGO_URL
521
534
  },
@@ -530,6 +543,7 @@ const shield = createShield({
530
543
  path: '/iri-shield',
531
544
  username: process.env.SHIELD_ADMIN_USER,
532
545
  password: process.env.SHIELD_ADMIN_PASSWORD,
546
+ sessionSecret: process.env.IRI_SHIELD_DASHBOARD_SECRET || process.env.SESSION_SECRET || process.env.JWT_SECRET,
533
547
  refreshMs: 60 * 1000
534
548
  }
535
549
  });
@@ -564,6 +578,7 @@ NODE_ENV=production
564
578
  APP_NAME=my-api
565
579
  SHIELD_ADMIN_USER=admin
566
580
  SHIELD_ADMIN_PASSWORD=replace-with-a-strong-secret
581
+ IRI_SHIELD_DASHBOARD_SECRET=replace-with-a-long-random-session-secret
567
582
  IRI_STORAGE_MODE=memory
568
583
  ```
569
584
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iri-shield",
3
- "version": "1.2.3",
3
+ "version": "1.2.5",
4
4
  "description": "Enterprise Express.js security middleware — explainable risk scoring, multi-signal identity profiling, behavioural anomaly detection, attack correlation, sensitive-data redaction, and dashboard monitoring.",
5
5
  "keywords": [
6
6
  "iri-shield",
package/src/dashboard.js CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const express = require('express');
4
- const { randomBytes, timingSafeEqual } = require('crypto');
4
+ const { createHmac, timingSafeEqual } = require('crypto');
5
5
 
6
6
  function createDashboardRouter({ storage, config }) {
7
7
  const router = express.Router();
@@ -46,60 +46,66 @@ function createDashboardRouter({ storage, config }) {
46
46
  // API endpoints
47
47
  // =========================================================================
48
48
 
49
- router.get('/api/stats', (req, res) => {
50
- res.json({ ...storage.getStats(), config: publicConfig(config) });
49
+ router.use('/api/', (_req, res, next) => {
50
+ res.setHeader('Cache-Control', 'no-store');
51
+ next();
51
52
  });
52
53
 
54
+ router.get('/api/stats', asyncRoute(async (req, res) => {
55
+ const stats = await callStorage(storage, 'getStats');
56
+ res.json({ ...stats, config: publicConfig(config) });
57
+ }));
58
+
53
59
  // Paginated events
54
- router.get('/api/events', (req, res) => {
60
+ router.get('/api/events', asyncRoute(async (req, res) => {
55
61
  const page = Math.max(1, parseInt(req.query.page) || 1);
56
62
  const perPage = Math.min(100, Math.max(5, parseInt(req.query.perPage) || 20));
57
63
  const riskLevel = req.query.riskLevel || null;
58
64
  if (typeof storage.getEvents === 'function') {
59
- res.json(storage.getEvents({ page, perPage, riskLevel }));
65
+ res.json(await callStorage(storage, 'getEvents', { page, perPage, riskLevel }));
60
66
  } else {
61
67
  const events = (storage.events || []).filter((e) => !riskLevel || e.riskLevel === riskLevel);
62
68
  res.json(paginateArray(events, page, perPage));
63
69
  }
64
- });
70
+ }));
65
71
 
66
72
  // Paginated clients
67
- router.get('/api/clients', (req, res) => {
73
+ router.get('/api/clients', asyncRoute(async (req, res) => {
68
74
  const page = Math.max(1, parseInt(req.query.page) || 1);
69
75
  const perPage = Math.min(100, Math.max(5, parseInt(req.query.perPage) || 20));
70
76
  if (typeof storage.getClients === 'function') {
71
- res.json(storage.getClients({ page, perPage }));
77
+ res.json(await callStorage(storage, 'getClients', { page, perPage }));
72
78
  } else {
73
79
  const clients = Array.from((storage.clients || new Map()).values()).sort(
74
80
  (a, b) => b.requestCount - a.requestCount
75
81
  );
76
82
  res.json(paginateArray(clients, page, perPage));
77
83
  }
78
- });
84
+ }));
79
85
 
80
86
  // Paginated blocked IPs with status filter
81
- router.get('/api/blocked', (req, res) => {
87
+ router.get('/api/blocked', asyncRoute(async (req, res) => {
82
88
  const page = Math.max(1, parseInt(req.query.page) || 1);
83
89
  const perPage = Math.min(100, Math.max(5, parseInt(req.query.perPage) || 20));
84
90
  const status = req.query.status || 'all';
85
91
  if (typeof storage.getBlockedIps === 'function') {
86
- res.json(storage.getBlockedIps({ page, perPage, status }));
92
+ res.json(await callStorage(storage, 'getBlockedIps', { page, perPage, status }));
87
93
  } else {
88
- res.json(storage.getBlockedIps({ page, perPage, status }));
94
+ res.json(await callStorage(storage, 'getBlockedIps', { page, perPage, status }));
89
95
  }
90
- });
96
+ }));
91
97
 
92
98
  // Paginated alerts
93
- router.get('/api/alerts', (req, res) => {
99
+ router.get('/api/alerts', asyncRoute(async (req, res) => {
94
100
  const page = Math.max(1, parseInt(req.query.page) || 1);
95
101
  const perPage = Math.min(100, Math.max(5, parseInt(req.query.perPage) || 20));
96
102
  const dismissed = req.query.dismissed === 'true';
97
103
  if (typeof storage.getAlerts === 'function') {
98
- res.json(storage.getAlerts({ page, perPage, dismissed }));
104
+ res.json(await callStorage(storage, 'getAlerts', { page, perPage, dismissed }));
99
105
  } else {
100
106
  res.json(paginateArray([], page, perPage));
101
107
  }
102
- });
108
+ }));
103
109
 
104
110
  // Unblock / Delete Block for an IP
105
111
  router.post('/api/unblock', (req, res) => {
@@ -711,6 +717,27 @@ function formatThreatLabel(name) {
711
717
  return cleaned.length > 12 ? cleaned.slice(0, 11) + '..' : cleaned;
712
718
  }
713
719
 
720
+ async function apiJson(url, options) {
721
+ const res = await fetch(url, Object.assign({ cache: 'no-store' }, options || {}));
722
+ if (res.status === 401) {
723
+ window.location.href = BASE + '/login';
724
+ throw new Error('Dashboard session expired');
725
+ }
726
+ let data = {};
727
+ try { data = await res.json(); } catch (_) { /* non-json response */ }
728
+ if (!res.ok) throw new Error(data.error || ('Request failed with status ' + res.status));
729
+ return data;
730
+ }
731
+
732
+ function normalizePageData(pageData) {
733
+ const data = pageData && typeof pageData === 'object' ? pageData : {};
734
+ const total = Number.isFinite(Number(data.total)) ? Number(data.total) : 0;
735
+ const perPage = Number.isFinite(Number(data.perPage)) && Number(data.perPage) > 0 ? Number(data.perPage) : 20;
736
+ const totalPages = Math.max(1, Number.isFinite(Number(data.totalPages)) ? Number(data.totalPages) : Math.ceil(total / perPage) || 1);
737
+ const page = Math.max(1, Math.min(Number.isFinite(Number(data.page)) ? Number(data.page) : 1, totalPages));
738
+ return { ...data, data: Array.isArray(data.data) ? data.data : [], total, perPage, totalPages, page };
739
+ }
740
+
714
741
  // -------------------------------------------------------------------------
715
742
  // Navigation & Panel Memory
716
743
  // -------------------------------------------------------------------------
@@ -826,13 +853,13 @@ async function doToggleBlock(ip, shouldBlock, reason = 'manual_action', duration
826
853
 
827
854
  try {
828
855
  if (shouldBlock) {
829
- await fetch(BASE + '/api/block', {
856
+ await apiJson(BASE + '/api/block', {
830
857
  method: 'POST',
831
858
  headers: { 'content-type': 'application/json' },
832
859
  body: JSON.stringify({ ip, reason: reason || 'manual_action', durationMs })
833
860
  });
834
861
  } else {
835
- await fetch(BASE + '/api/unblock', {
862
+ await apiJson(BASE + '/api/unblock', {
836
863
  method: 'POST',
837
864
  headers: { 'content-type': 'application/json' },
838
865
  body: JSON.stringify({ ip })
@@ -858,8 +885,7 @@ async function doToggleBlock(ip, shouldBlock, reason = 'manual_action', duration
858
885
 
859
886
  async function loadStats() {
860
887
  try {
861
- const res = await fetch(BASE + '/api/stats');
862
- const data = await res.json();
888
+ const data = await apiJson(BASE + '/api/stats');
863
889
 
864
890
  refreshMs = Number(data.config?.dashboard?.refreshMs || refreshMs);
865
891
  document.getElementById('refreshLabel').textContent = Math.round(refreshMs / 1000) + 's';
@@ -954,8 +980,7 @@ async function loadEvents(page) {
954
980
  const risk = document.getElementById('eventRiskFilter')?.value || '';
955
981
  try {
956
982
  const url = BASE + '/api/events?page=' + currentPage.events + '&perPage=20' + (risk ? '&riskLevel=' + risk : '');
957
- const res = await fetch(url);
958
- const data = await res.json();
983
+ const data = normalizePageData(await apiJson(url));
959
984
  rawEventsData = data.data || [];
960
985
  const tbody = document.getElementById('eventRows');
961
986
  if (!rawEventsData.length) {
@@ -1103,8 +1128,7 @@ function toggleEventDetail(id) {
1103
1128
  async function loadClients(page) {
1104
1129
  currentPage.clients = page || currentPage.clients;
1105
1130
  try {
1106
- const res = await fetch(BASE + '/api/clients?page=' + currentPage.clients + '&perPage=20');
1107
- const data = await res.json();
1131
+ const data = normalizePageData(await apiJson(BASE + '/api/clients?page=' + currentPage.clients + '&perPage=20'));
1108
1132
  const tbody = document.getElementById('clientRows');
1109
1133
  if (!data.data?.length) {
1110
1134
  tbody.innerHTML = '<tr><td class="px-4 py-4 text-gray-400 text-center" colspan="9">No clients recorded</td></tr>';
@@ -1137,8 +1161,7 @@ async function loadBlocked(page) {
1137
1161
  currentPage.blocked = page || currentPage.blocked;
1138
1162
  const status = document.getElementById('blockedStatusFilter')?.value || 'all';
1139
1163
  try {
1140
- const res = await fetch(BASE + '/api/blocked?page=' + currentPage.blocked + '&perPage=20&status=' + status);
1141
- const data = await res.json();
1164
+ const data = normalizePageData(await apiJson(BASE + '/api/blocked?page=' + currentPage.blocked + '&perPage=20&status=' + status));
1142
1165
  const tbody = document.getElementById('blockedRows');
1143
1166
  if (!data.data?.length) {
1144
1167
  tbody.innerHTML = '<tr><td class="px-4 py-4 text-gray-400 text-center" colspan="8">No IP addresses recorded in blocklist</td></tr>';
@@ -1183,7 +1206,7 @@ async function loadBlocked(page) {
1183
1206
  async function doDeleteBlock(ip) {
1184
1207
  if (!confirm('Permanently remove IP ' + ip + ' from block history?')) return;
1185
1208
  try {
1186
- await fetch(BASE + '/api/unblock', {
1209
+ await apiJson(BASE + '/api/unblock', {
1187
1210
  method: 'POST',
1188
1211
  headers: { 'content-type': 'application/json' },
1189
1212
  body: JSON.stringify({ ip })
@@ -1202,8 +1225,7 @@ async function doDeleteBlock(ip) {
1202
1225
  async function loadAlerts(page) {
1203
1226
  currentPage.alerts = page || currentPage.alerts;
1204
1227
  try {
1205
- const res = await fetch(BASE + '/api/alerts?page=' + currentPage.alerts + '&perPage=20');
1206
- const data = await res.json();
1228
+ const data = normalizePageData(await apiJson(BASE + '/api/alerts?page=' + currentPage.alerts + '&perPage=20'));
1207
1229
  const tbody = document.getElementById('alertRows');
1208
1230
  if (!data.data?.length) {
1209
1231
  tbody.innerHTML = '<tr><td class="px-4 py-4 text-gray-400 text-center" colspan="8">No active alerts</td></tr>';
@@ -1231,7 +1253,7 @@ async function loadAlerts(page) {
1231
1253
 
1232
1254
  async function doDismiss(clientId) {
1233
1255
  try {
1234
- await fetch(BASE + '/api/alerts/' + encodeURIComponent(clientId) + '/dismiss', { method: 'POST' });
1256
+ await apiJson(BASE + '/api/alerts/' + encodeURIComponent(clientId) + '/dismiss', { method: 'POST' });
1235
1257
  loadAlerts(currentPage.alerts);
1236
1258
  loadStats();
1237
1259
  } catch(e) { console.error('Dismiss error', e); }
@@ -1261,8 +1283,7 @@ async function submitBlock() {
1261
1283
 
1262
1284
  async function loadSettings() {
1263
1285
  try {
1264
- const res = await fetch(BASE + '/api/settings');
1265
- const data = await res.json();
1286
+ const data = await apiJson(BASE + '/api/settings');
1266
1287
  if (data.security) selectMode(data.security);
1267
1288
  } catch(e) { console.error('Load settings error', e); }
1268
1289
  }
@@ -1283,12 +1304,11 @@ document.getElementById('settingsForm').addEventListener('submit', async functio
1283
1304
  if (modeEl) payload.security = modeEl.value;
1284
1305
 
1285
1306
  try {
1286
- const res = await fetch(BASE + '/api/settings', {
1307
+ const updated = await apiJson(BASE + '/api/settings', {
1287
1308
  method: 'POST',
1288
1309
  headers: { 'content-type': 'application/json' },
1289
1310
  body: JSON.stringify(payload)
1290
1311
  });
1291
- const updated = await res.json();
1292
1312
  if (updated.security) selectMode(updated.security);
1293
1313
 
1294
1314
  const msg = document.getElementById('settingsMsg');
@@ -1376,9 +1396,15 @@ function renderChart(rows) {
1376
1396
  // -------------------------------------------------------------------------
1377
1397
 
1378
1398
  function renderPagination(containerId, pageData, loadFn) {
1399
+ pageData = normalizePageData(pageData);
1379
1400
  const container = document.getElementById(containerId);
1380
- if (!container || pageData.totalPages <= 1) {
1381
- if (container) container.innerHTML = '';
1401
+ if (!container) return;
1402
+ if (pageData.total === 0) {
1403
+ container.innerHTML = '<div class="text-xs text-gray-500 font-medium">Showing <strong>0</strong> records</div>';
1404
+ return;
1405
+ }
1406
+ if (pageData.totalPages <= 1) {
1407
+ container.innerHTML = \`<div class="text-xs text-gray-500 font-medium">Showing <strong>1-\${fmt.format(pageData.total)}</strong> of <strong>\${fmt.format(pageData.total)}</strong> records</div>\`;
1382
1408
  return;
1383
1409
  }
1384
1410
  const { page, totalPages, total, perPage } = pageData;
@@ -1453,31 +1479,93 @@ function renderLogin(config, failed) {
1453
1479
  <head>
1454
1480
  <meta charset="utf-8" />
1455
1481
  <meta name="viewport" content="width=device-width, initial-scale=1" />
1456
- <title>iri-shield Login</title>
1482
+ <title>iri-shield - Dashboard Login</title>
1457
1483
  <script src="https://cdn.tailwindcss.com"></script>
1484
+ <script>
1485
+ tailwind.config = {
1486
+ theme: {
1487
+ extend: {
1488
+ fontFamily: {
1489
+ sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
1490
+ display: ['Syne', 'Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
1491
+ mono: ['JetBrains Mono', 'ui-monospace', 'SFMono-Regular', 'monospace']
1492
+ },
1493
+ colors: {
1494
+ paper: '#fbfbf8',
1495
+ line: '#e5e7eb',
1496
+ cobalt: '#d81d87',
1497
+ rose: '#be123c',
1498
+ pine: '#047857'
1499
+ }
1500
+ }
1501
+ }
1502
+ };
1503
+ </script>
1504
+ <link rel="preconnect" href="https://fonts.googleapis.com">
1505
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
1506
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&family=Syne:wght@600;700;800&display=swap" rel="stylesheet">
1458
1507
  </head>
1459
- <body class="min-h-screen bg-gray-50 text-gray-900">
1460
- <main class="flex min-h-screen items-center justify-center px-4">
1461
- <div class="w-full max-w-sm">
1462
- <div class="text-center mb-8">
1463
- <div class="inline-flex w-12 h-12 rounded-xl bg-blue-600 items-center justify-center mb-3 shadow-md">
1464
- <svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1465
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
1466
- </svg>
1508
+ <body class="min-h-screen bg-paper text-slate-950 antialiased">
1509
+ <main class="grid min-h-screen grid-cols-1 lg:grid-cols-[0.58fr_0.42fr]">
1510
+ <section class="flex items-center border-b border-line bg-white px-6 py-12 lg:border-b-0 lg:border-r lg:px-12">
1511
+ <div class="mx-auto w-full max-w-3xl">
1512
+ <div class="mb-8 flex items-center gap-3">
1513
+ <div class="grid h-11 w-11 place-items-center rounded-md bg-cobalt text-sm font-black text-white shadow-sm">IS</div>
1514
+ <div>
1515
+ <p class="text-sm font-extrabold">iri-shield</p>
1516
+ <p class="text-[11px] font-bold uppercase tracking-[0.18em] text-slate-500">Security dashboard</p>
1517
+ </div>
1518
+ </div>
1519
+ <!-- <p class="mb-5 inline-flex rounded-full border border-blue-200 bg-blue-50 px-3 py-1 text-xs font-bold uppercase tracking-[0.18em] text-cobalt">Research telemetry console</p> -->
1520
+ <h1 class="font-display text-5xl font-extrabold leading-[0.98] tracking-normal md:text-6xl">Monitor API threats with explainable decisions.</h1>
1521
+ <p class="mt-6 max-w-2xl text-lg leading-8 text-slate-600">Sign in to inspect alerts, event traces, identity drift, blocked IPs, storage health, and redaction activity generated by the Iri-Shield middleware.</p>
1522
+ <div class="mt-8 grid grid-cols-1 gap-3 sm:grid-cols-3">
1523
+ <div class="rounded-lg border border-line bg-slate-50 p-4">
1524
+ <p class="font-mono text-xs font-bold text-cobalt">01</p>
1525
+ <p class="mt-2 text-sm font-extrabold">Detect</p>
1526
+ <p class="mt-1 text-xs leading-5 text-slate-500">Rules, anomalies, and attack sequences.</p>
1527
+ </div>
1528
+ <div class="rounded-lg border border-line bg-slate-50 p-4">
1529
+ <p class="font-mono text-xs font-bold text-cobalt">02</p>
1530
+ <p class="mt-2 text-sm font-extrabold">Explain</p>
1531
+ <p class="mt-1 text-xs leading-5 text-slate-500">Risk scores with evidence breakdowns.</p>
1532
+ </div>
1533
+ <div class="rounded-lg border border-line bg-slate-50 p-4">
1534
+ <p class="font-mono text-xs font-bold text-cobalt">03</p>
1535
+ <p class="mt-2 text-sm font-extrabold">Mitigate</p>
1536
+ <p class="mt-1 text-xs leading-5 text-slate-500">Alerts, blocking, and redaction.</p>
1537
+ </div>
1467
1538
  </div>
1468
- <h1 class="text-2xl font-bold text-gray-900">iri-shield</h1>
1469
- <p class="text-sm text-gray-500 mt-1">Enterprise Security Dashboard</p>
1470
1539
  </div>
1471
- <form method="post" action="${escapeHtml(config.dashboard?.path || '/iri-shield')}/login" class="bg-white rounded-2xl border border-gray-200 shadow-sm p-6">
1472
- ${failed ? '<div class="mb-4 rounded-lg bg-red-50 border border-red-200 px-3 py-2 text-sm text-red-700 font-medium">Invalid username or password.</div>' : ''}
1473
- <label class="block text-sm font-medium text-gray-700 mb-1" for="username">Username</label>
1474
- <input class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 mb-4" id="username" name="username" autocomplete="username" required />
1475
- <label class="block text-sm font-medium text-gray-700 mb-1" for="password">Password</label>
1476
- <input class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 mb-5" id="password" name="password" type="password" autocomplete="current-password" required />
1477
- <button class="w-full rounded-lg bg-blue-600 px-4 py-2.5 font-semibold text-white hover:bg-blue-700 transition-colors shadow-sm" type="submit">Sign in</button>
1478
- </form>
1479
- <p class="text-center text-xs text-gray-400 mt-4">${escapeHtml(config.appName || 'iri-shield')} Security Platform</p>
1480
- </div>
1540
+ </section>
1541
+
1542
+ <section class="flex items-center px-6 py-12 lg:px-12">
1543
+ <div class="mx-auto w-full max-w-md">
1544
+ <div class="rounded-lg border border-line bg-white p-6 shadow-xl shadow-slate-200/60">
1545
+ <div class="mb-6">
1546
+ <p class="text-xs font-bold uppercase tracking-[0.18em] text-slate-500">Authenticated access</p>
1547
+ <h2 class="mt-2 font-display text-3xl font-bold tracking-normal">Dashboard sign in</h2>
1548
+ <p class="mt-2 text-sm leading-6 text-slate-600">Use the configured administrator credentials for ${escapeHtml(config.appName || 'iri-shield')}.</p>
1549
+ </div>
1550
+ ${failed ? '<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">Invalid username or password.</div>' : ''}
1551
+ <form method="post" action="${escapeHtml(config.dashboard?.path || '/iri-shield')}/login" class="space-y-4">
1552
+ <label class="block">
1553
+ <span class="mb-1 block text-sm font-bold text-slate-700">Username</span>
1554
+ <input class="w-full rounded-md border border-line bg-white px-3 py-2.5 text-sm outline-none transition focus:border-cobalt focus:ring-4 focus:ring-blue-100" id="username" name="username" autocomplete="username" required />
1555
+ </label>
1556
+ <label class="block">
1557
+ <span class="mb-1 block text-sm font-bold text-slate-700">Password</span>
1558
+ <input class="w-full rounded-md border border-line bg-white px-3 py-2.5 text-sm outline-none transition focus:border-cobalt focus:ring-4 focus:ring-blue-100" id="password" name="password" type="password" autocomplete="current-password" required />
1559
+ </label>
1560
+ <button class="w-full rounded-md bg-cobalt px-4 py-3 text-sm font-extrabold text-white shadow-sm transition hover:bg-blue-700" type="submit">Sign in to dashboard</button>
1561
+ </form>
1562
+ </div>
1563
+ <div class="mt-4 rounded-lg border border-dashed border-slate-300 bg-slate-50 p-4">
1564
+ <p class="text-xs font-bold uppercase tracking-[0.18em] text-slate-500">Media slot</p>
1565
+ <p class="mt-2 text-sm leading-6 text-slate-600">Optional future visual: a compact looping animation of alert cards entering the dashboard, risk scores rising, and sensitive fields being masked.</p>
1566
+ </div>
1567
+ </div>
1568
+ </section>
1481
1569
  </main>
1482
1570
  </body>
1483
1571
  </html>`;
@@ -1561,7 +1649,15 @@ function iconAlert() { return '<svg class="w-5 h-5 text-blue-600" fill="none"
1561
1649
  function dashboardAuth(options = {}) {
1562
1650
  const username = options.username || 'admin';
1563
1651
  const password = options.password || 'admin';
1564
- const token = randomBytes(32).toString('hex');
1652
+ const sessionSecret =
1653
+ options.sessionSecret ||
1654
+ process.env.IRI_SHIELD_DASHBOARD_SECRET ||
1655
+ process.env.SESSION_SECRET ||
1656
+ process.env.JWT_SECRET ||
1657
+ `${username}:${password}`;
1658
+ const token = createHmac('sha256', String(sessionSecret))
1659
+ .update(`iri-shield-dashboard:${username}:${password}`)
1660
+ .digest('hex');
1565
1661
  return {
1566
1662
  token,
1567
1663
  canLogin(inputUser, inputPass) {
@@ -1573,6 +1669,20 @@ function dashboardAuth(options = {}) {
1573
1669
  };
1574
1670
  }
1575
1671
 
1672
+ function asyncRoute(handler) {
1673
+ return function routeHandler(req, res, next) {
1674
+ Promise.resolve(handler(req, res, next)).catch(next);
1675
+ };
1676
+ }
1677
+
1678
+ async function callStorage(storage, method, ...args) {
1679
+ if (storage.ready && typeof storage.ready.then === 'function') {
1680
+ await storage.ready;
1681
+ }
1682
+ const result = storage[method](...args);
1683
+ return result && typeof result.then === 'function' ? await result : result;
1684
+ }
1685
+
1576
1686
  function applyDashboardSettings(config, body) {
1577
1687
  const numberFields = [
1578
1688
  ['rateLimit.max', 1, 100000],
@@ -1626,6 +1736,7 @@ function applyDashboardSettings(config, body) {
1626
1736
  function publicConfig(config) {
1627
1737
  const copy = JSON.parse(JSON.stringify(config));
1628
1738
  if (copy.dashboard?.password) copy.dashboard.password = '';
1739
+ if (copy.dashboard?.sessionSecret) copy.dashboard.sessionSecret = '';
1629
1740
  return copy;
1630
1741
  }
1631
1742
 
package/src/index.js CHANGED
@@ -651,6 +651,7 @@ function mergeInto(target, patch) {
651
651
  function sanitizeConfig(config) {
652
652
  const copy = JSON.parse(JSON.stringify(config));
653
653
  if (copy.dashboard && copy.dashboard.password) copy.dashboard.password = '';
654
+ if (copy.dashboard && copy.dashboard.sessionSecret) copy.dashboard.sessionSecret = '';
654
655
  return copy;
655
656
  }
656
657
 
@@ -19,8 +19,8 @@ class MongoStorage extends MemoryStorage {
19
19
  this.db = null;
20
20
  this._cols = {};
21
21
 
22
- // Start connection asynchronously — does not block constructor
23
- this._connect();
22
+ // Start connection asynchronously — does not block constructor.
23
+ this.ready = this._connect();
24
24
  }
25
25
 
26
26
  async _connect() {
@@ -47,8 +47,8 @@ class MongoStorage extends MemoryStorage {
47
47
  // Initialize collections and TTL indexes
48
48
  await this._initCollections();
49
49
 
50
- // Load persistent blocks into memory
51
- await this._loadPersistentBlocks();
50
+ // Warm the in-memory dashboard cache after cold starts.
51
+ await this._loadAllPersistentData();
52
52
 
53
53
  console.log('[iri-shield] MongoDB connected:', this.mongoUrl);
54
54
  } catch (err) {
@@ -86,6 +86,14 @@ class MongoStorage extends MemoryStorage {
86
86
  this._cols = { requests, events, clients, blocks, alerts };
87
87
  }
88
88
 
89
+ async _loadAllPersistentData() {
90
+ await this._loadPersistentBlocks();
91
+ await this._loadPersistentClients();
92
+ await this._loadPersistentAlerts();
93
+ await this._loadPersistentEvents();
94
+ await this._loadPersistentRequests();
95
+ }
96
+
89
97
  async _loadPersistentBlocks() {
90
98
  if (!this.connected) return;
91
99
  try {
@@ -104,6 +112,86 @@ class MongoStorage extends MemoryStorage {
104
112
  } catch { /* ignore */ }
105
113
  }
106
114
 
115
+ async _loadPersistentClients() {
116
+ if (!this.connected) return;
117
+ try {
118
+ const rows = await this._cols.clients.find({}).sort({ requestCount: -1 }).limit(this.maxClients).toArray();
119
+ for (const row of rows) {
120
+ if (row.clientId) this.clients.set(row.clientId, stripMongoId(row));
121
+ }
122
+ } catch { /* ignore */ }
123
+ }
124
+
125
+ async _loadPersistentAlerts() {
126
+ if (!this.connected) return;
127
+ try {
128
+ const rows = await this._cols.alerts.find({}).sort({ lastAlertAt: -1 }).limit(this.maxClients).toArray();
129
+ for (const row of rows) {
130
+ if (row.clientId) this.alerts.set(row.clientId, stripMongoId(row));
131
+ }
132
+ } catch { /* ignore */ }
133
+ }
134
+
135
+ async _loadPersistentEvents() {
136
+ if (!this.connected) return;
137
+ try {
138
+ const rows = await this._cols.events.find({}).sort({ timestamp: -1, createdAt: -1 }).limit(this.maxEvents).toArray();
139
+ this.events = rows.map(stripMongoId);
140
+ this.threatDistribution = new Map();
141
+ for (const event of this.events) {
142
+ const threat = event.threat || 'unknown';
143
+ this.threatDistribution.set(threat, (this.threatDistribution.get(threat) || 0) + 1);
144
+ }
145
+ } catch { /* ignore */ }
146
+ }
147
+
148
+ async _loadPersistentRequests() {
149
+ if (!this.connected) return;
150
+ try {
151
+ const rows = await this._cols.requests.find({}).sort({ createdAt: -1 }).limit(this.maxRequests).toArray();
152
+ this.requests = rows.map(stripMongoId);
153
+ const totals = await this._cols.requests.aggregate([
154
+ {
155
+ $group: {
156
+ _id: null,
157
+ totalRequests: { $sum: 1 },
158
+ blockedRequests: { $sum: { $cond: ['$blocked', 1, 0] } },
159
+ totalLatencyMs: { $sum: { $ifNull: ['$durationMs', 0] } }
160
+ }
161
+ }
162
+ ]).next();
163
+ this.totalRequests = totals?.totalRequests || this.requests.length;
164
+ this.blockedRequests = totals?.blockedRequests || 0;
165
+ this.totalLatencyMs = totals?.totalLatencyMs || 0;
166
+
167
+ const endpoints = await this._cols.requests.aggregate([
168
+ {
169
+ $group: {
170
+ _id: { method: '$method', endpoint: '$endpoint' },
171
+ count: { $sum: 1 },
172
+ errors: { $sum: { $cond: [{ $gte: ['$statusCode', 400] }, 1, 0] } },
173
+ avgLatencyMs: { $avg: '$durationMs' }
174
+ }
175
+ },
176
+ { $sort: { count: -1 } },
177
+ { $limit: 20 }
178
+ ]).toArray();
179
+
180
+ this.endpointStats = new Map();
181
+ for (const row of endpoints) {
182
+ const method = row._id?.method || 'GET';
183
+ const endpoint = row._id?.endpoint || '/';
184
+ this.endpointStats.set(`${method} ${endpoint}`, {
185
+ method,
186
+ endpoint,
187
+ count: row.count || 0,
188
+ errors: row.errors || 0,
189
+ avgLatencyMs: row.avgLatencyMs || 0
190
+ });
191
+ }
192
+ } catch { /* ignore */ }
193
+ }
194
+
107
195
  // ---------------------------------------------------------------------------
108
196
  // Helpers
109
197
  // ---------------------------------------------------------------------------
@@ -120,9 +208,11 @@ class MongoStorage extends MemoryStorage {
120
208
 
121
209
  recordRequest(request) {
122
210
  super.recordRequest(request);
211
+ const timestamp = new Date().toISOString();
123
212
  this._safe(() =>
124
213
  this._cols.requests.insertOne({
125
214
  ...request,
215
+ timestamp,
126
216
  createdAt: new Date(),
127
217
  blocked: Boolean(request.blocked)
128
218
  })
@@ -220,6 +310,62 @@ class MongoStorage extends MemoryStorage {
220
310
  return result;
221
311
  }
222
312
 
313
+ async getEvents({ page = 1, perPage = 20, riskLevel = null } = {}) {
314
+ if (!this.connected) return super.getEvents({ page, perPage, riskLevel });
315
+ await this.ready;
316
+ if (!this.connected) return super.getEvents({ page, perPage, riskLevel });
317
+ try {
318
+ const filter = riskLevel ? { riskLevel } : {};
319
+ const total = await this._cols.events.countDocuments(filter);
320
+ const data = await this._cols.events
321
+ .find(filter)
322
+ .sort({ timestamp: -1, createdAt: -1 })
323
+ .skip((Math.max(1, page) - 1) * perPage)
324
+ .limit(perPage)
325
+ .toArray();
326
+ return paginateMongo(data, total, page, perPage);
327
+ } catch {
328
+ return super.getEvents({ page, perPage, riskLevel });
329
+ }
330
+ }
331
+
332
+ async getClients({ page = 1, perPage = 20 } = {}) {
333
+ if (!this.connected) return super.getClients({ page, perPage });
334
+ await this.ready;
335
+ if (!this.connected) return super.getClients({ page, perPage });
336
+ try {
337
+ const total = await this._cols.clients.countDocuments({});
338
+ const data = await this._cols.clients
339
+ .find({})
340
+ .sort({ requestCount: -1, lastSeenAt: -1 })
341
+ .skip((Math.max(1, page) - 1) * perPage)
342
+ .limit(perPage)
343
+ .toArray();
344
+ return paginateMongo(data, total, page, perPage);
345
+ } catch {
346
+ return super.getClients({ page, perPage });
347
+ }
348
+ }
349
+
350
+ async getAlerts({ page = 1, perPage = 20, dismissed = false } = {}) {
351
+ if (!this.connected) return super.getAlerts({ page, perPage, dismissed });
352
+ await this.ready;
353
+ if (!this.connected) return super.getAlerts({ page, perPage, dismissed });
354
+ try {
355
+ const filter = { dismissed: Boolean(dismissed) };
356
+ const total = await this._cols.alerts.countDocuments(filter);
357
+ const data = await this._cols.alerts
358
+ .find(filter)
359
+ .sort({ lastAlertAt: -1, updatedAt: -1 })
360
+ .skip((Math.max(1, page) - 1) * perPage)
361
+ .limit(perPage)
362
+ .toArray();
363
+ return paginateMongo(data, total, page, perPage);
364
+ } catch {
365
+ return super.getAlerts({ page, perPage, dismissed });
366
+ }
367
+ }
368
+
223
369
  // ---------------------------------------------------------------------------
224
370
  // Stats
225
371
  // ---------------------------------------------------------------------------
@@ -235,3 +381,21 @@ class MongoStorage extends MemoryStorage {
235
381
  }
236
382
 
237
383
  module.exports = { MongoStorage };
384
+
385
+ function stripMongoId(row) {
386
+ if (!row) return row;
387
+ const { _id, ...rest } = row;
388
+ return rest;
389
+ }
390
+
391
+ function paginateMongo(rows, total, page, perPage) {
392
+ const totalPages = Math.ceil(total / perPage) || 1;
393
+ const safePage = Math.max(1, Math.min(page, totalPages));
394
+ return {
395
+ data: rows.map(stripMongoId),
396
+ total,
397
+ page: safePage,
398
+ perPage,
399
+ totalPages
400
+ };
401
+ }