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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thatcher",
3
- "version": "1.0.65",
3
+ "version": "1.0.66",
4
4
  "description": "A config-driven application framework for building data-intensive web apps without code.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -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
+ }
@@ -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">${[