iri-shield 1.2.3 → 1.2.4

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.4",
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;
@@ -1561,7 +1587,15 @@ function iconAlert() { return '<svg class="w-5 h-5 text-blue-600" fill="none"
1561
1587
  function dashboardAuth(options = {}) {
1562
1588
  const username = options.username || 'admin';
1563
1589
  const password = options.password || 'admin';
1564
- const token = randomBytes(32).toString('hex');
1590
+ const sessionSecret =
1591
+ options.sessionSecret ||
1592
+ process.env.IRI_SHIELD_DASHBOARD_SECRET ||
1593
+ process.env.SESSION_SECRET ||
1594
+ process.env.JWT_SECRET ||
1595
+ `${username}:${password}`;
1596
+ const token = createHmac('sha256', String(sessionSecret))
1597
+ .update(`iri-shield-dashboard:${username}:${password}`)
1598
+ .digest('hex');
1565
1599
  return {
1566
1600
  token,
1567
1601
  canLogin(inputUser, inputPass) {
@@ -1573,6 +1607,20 @@ function dashboardAuth(options = {}) {
1573
1607
  };
1574
1608
  }
1575
1609
 
1610
+ function asyncRoute(handler) {
1611
+ return function routeHandler(req, res, next) {
1612
+ Promise.resolve(handler(req, res, next)).catch(next);
1613
+ };
1614
+ }
1615
+
1616
+ async function callStorage(storage, method, ...args) {
1617
+ if (storage.ready && typeof storage.ready.then === 'function') {
1618
+ await storage.ready;
1619
+ }
1620
+ const result = storage[method](...args);
1621
+ return result && typeof result.then === 'function' ? await result : result;
1622
+ }
1623
+
1576
1624
  function applyDashboardSettings(config, body) {
1577
1625
  const numberFields = [
1578
1626
  ['rateLimit.max', 1, 100000],
@@ -1626,6 +1674,7 @@ function applyDashboardSettings(config, body) {
1626
1674
  function publicConfig(config) {
1627
1675
  const copy = JSON.parse(JSON.stringify(config));
1628
1676
  if (copy.dashboard?.password) copy.dashboard.password = '';
1677
+ if (copy.dashboard?.sessionSecret) copy.dashboard.sessionSecret = '';
1629
1678
  return copy;
1630
1679
  }
1631
1680
 
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
+ }