thatcher 1.0.65 → 1.0.66
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/package.json +1 -1
- package/src/lib/rate-limiter.js +37 -0
- package/src/server/server.js +14 -0
- package/src/ui/dashboard-renderer.js +7 -7
package/package.json
CHANGED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Fixed-window per-key rate limiter, in-memory. Keyed by authenticated user id
|
|
2
|
+
// when available, otherwise by remote IP, so one anonymous IP hammering the
|
|
3
|
+
// API can't exhaust a budget shared with real users behind the same NAT --
|
|
4
|
+
// each authenticated user gets their own independent window.
|
|
5
|
+
const WINDOW_MS = 60 * 1000;
|
|
6
|
+
const DEFAULT_LIMIT = 300;
|
|
7
|
+
const buckets = new Map();
|
|
8
|
+
|
|
9
|
+
let sweepHandle = null;
|
|
10
|
+
function ensureSweep() {
|
|
11
|
+
if (sweepHandle) return;
|
|
12
|
+
sweepHandle = setInterval(() => {
|
|
13
|
+
const now = Date.now();
|
|
14
|
+
for (const [key, bucket] of buckets) {
|
|
15
|
+
if (now - bucket.windowStart > WINDOW_MS) buckets.delete(key);
|
|
16
|
+
}
|
|
17
|
+
}, WINDOW_MS);
|
|
18
|
+
if (sweepHandle.unref) sweepHandle.unref();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function checkRateLimit(key, limit = DEFAULT_LIMIT) {
|
|
22
|
+
ensureSweep();
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
let bucket = buckets.get(key);
|
|
25
|
+
if (!bucket || now - bucket.windowStart >= WINDOW_MS) {
|
|
26
|
+
bucket = { windowStart: now, count: 0 };
|
|
27
|
+
buckets.set(key, bucket);
|
|
28
|
+
}
|
|
29
|
+
bucket.count += 1;
|
|
30
|
+
const remaining = Math.max(0, limit - bucket.count);
|
|
31
|
+
const resetMs = bucket.windowStart + WINDOW_MS - now;
|
|
32
|
+
return { allowed: bucket.count <= limit, remaining, resetMs, limit };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function resetRateLimiter() {
|
|
36
|
+
buckets.clear();
|
|
37
|
+
}
|
package/src/server/server.js
CHANGED
|
@@ -93,6 +93,20 @@ export function createServer(options) {
|
|
|
93
93
|
// API routes
|
|
94
94
|
if (pathname.startsWith('/api/')) {
|
|
95
95
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
96
|
+
|
|
97
|
+
const { checkRateLimit } = await import('../lib/rate-limiter.js');
|
|
98
|
+
const rateUser = await resolveRequestUser(req);
|
|
99
|
+
const rateKey = rateUser ? `user:${rateUser.id}` : `ip:${req.socket?.remoteAddress || 'unknown'}`;
|
|
100
|
+
const rate = checkRateLimit(rateKey);
|
|
101
|
+
res.setHeader('X-RateLimit-Limit', String(rate.limit));
|
|
102
|
+
res.setHeader('X-RateLimit-Remaining', String(rate.remaining));
|
|
103
|
+
res.setHeader('X-RateLimit-Reset', String(Math.ceil(rate.resetMs / 1000)));
|
|
104
|
+
if (!rate.allowed) {
|
|
105
|
+
res.setHeader('Retry-After', String(Math.ceil(rate.resetMs / 1000)));
|
|
106
|
+
res.writeHead(429);
|
|
107
|
+
res.end(JSON.stringify({ error: 'Rate limit exceeded, try again later' }));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
96
110
|
const parts = pathname.slice(5).split('/').filter(Boolean); // remove /api/
|
|
97
111
|
|
|
98
112
|
if (parts.length === 0) {
|
|
@@ -89,12 +89,12 @@ export function renderAuditDashboard(user, auditData = {}) {
|
|
|
89
89
|
const { summary = {}, recentActivity = [] } = auditData;
|
|
90
90
|
const actRows = recentActivity.slice(0, 20).map(a =>
|
|
91
91
|
`<tr data-row>
|
|
92
|
-
<td data-col="time">${new Date((a.timestamp||a.created_at)*1000).toLocaleString('en-ZA')}</td>
|
|
93
|
-
<td data-col="action"><span class="pill pill-info">${a.action||'-'}</span></td>
|
|
94
|
-
<td data-col="entity">${a.entity_type||'-'}</td>
|
|
95
|
-
<td data-col="id" style="font-size:12px">${a.entity_id||'-'}</td>
|
|
96
|
-
<td data-col="user">${a.user_name||a.user_id||'-'}</td>
|
|
97
|
-
<td data-col="reason" style="font-size:12px;color:var(--color-text-muted)">${a.reason||'-'}</td>
|
|
92
|
+
<td data-col="time">${esc(new Date((a.timestamp||a.created_at)*1000).toLocaleString('en-ZA'))}</td>
|
|
93
|
+
<td data-col="action"><span class="pill pill-info">${esc(a.action||'-')}</span></td>
|
|
94
|
+
<td data-col="entity">${esc(a.entity_type||'-')}</td>
|
|
95
|
+
<td data-col="id" style="font-size:12px">${esc(a.entity_id||'-')}</td>
|
|
96
|
+
<td data-col="user">${esc(a.user_name||a.user_id||'-')}</td>
|
|
97
|
+
<td data-col="reason" style="font-size:12px;color:var(--color-text-muted)">${esc(a.reason||'-')}</td>
|
|
98
98
|
</tr>`
|
|
99
99
|
).join('') || emptyRow(6, 'No audit records found');
|
|
100
100
|
|
|
@@ -124,7 +124,7 @@ export function renderAuditDashboard(user, auditData = {}) {
|
|
|
124
124
|
export function renderSystemHealth(user, healthData = {}) {
|
|
125
125
|
const { database = {}, server: srv = {}, entities = {} } = healthData;
|
|
126
126
|
const entRows = Object.entries(entities).map(([n, c]) =>
|
|
127
|
-
`<tr data-row><td data-col="entity">${n}</td><td data-col="count" style="text-align:right">${c}</td></tr>`
|
|
127
|
+
`<tr data-row><td data-col="entity">${esc(n)}</td><td data-col="count" style="text-align:right">${esc(String(c))}</td></tr>`
|
|
128
128
|
).join('') || emptyRow(2, 'No data');
|
|
129
129
|
|
|
130
130
|
const statsHtml = `<div class="stats-row">${[
|