claw-dashboard 1.9.0 → 2.0.0

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.
Files changed (135) hide show
  1. package/.c8rc.json +38 -0
  2. package/.dockerignore +68 -0
  3. package/.github/dependabot.yml +45 -0
  4. package/.github/pull_request_template.md +39 -0
  5. package/.github/workflows/ci.yml +147 -0
  6. package/.github/workflows/release.yml +40 -0
  7. package/.github/workflows/security.yml +84 -0
  8. package/.husky/pre-commit +1 -0
  9. package/.planning/codebase/ARCHITECTURE.md +206 -0
  10. package/.planning/codebase/INTEGRATIONS.md +150 -0
  11. package/.planning/codebase/STACK.md +122 -0
  12. package/.planning/codebase/STRUCTURE.md +201 -0
  13. package/CHANGELOG.md +293 -0
  14. package/CONTRIBUTING.md +378 -0
  15. package/Dockerfile +54 -0
  16. package/FEATURES.md +195 -21
  17. package/README.md +83 -6
  18. package/TODO.md +28 -0
  19. package/build-cjs.js +127 -0
  20. package/cjs-shim.js +13 -0
  21. package/dist/clawdash +1729 -0
  22. package/dist/clawdash.meta.json +2236 -0
  23. package/dist/widgets.cjs +6511 -0
  24. package/docker-compose.yml +67 -0
  25. package/docs/API.md +1050 -0
  26. package/docs/PLUGINS.md +1504 -0
  27. package/esbuild.config.js +158 -0
  28. package/eslint.config.js +56 -0
  29. package/examples/plugins/README.md +122 -0
  30. package/examples/plugins/api-status/index.js +294 -0
  31. package/examples/plugins/api-status/plugin.json +19 -0
  32. package/examples/plugins/hello-world/index.js +104 -0
  33. package/examples/plugins/hello-world/plugin.json +15 -0
  34. package/examples/plugins/system-metrics-chart/index.js +339 -0
  35. package/examples/plugins/system-metrics-chart/plugin.json +18 -0
  36. package/examples/plugins/weather-widget/index.js +136 -0
  37. package/examples/plugins/weather-widget/plugin.json +19 -0
  38. package/index.cjs +23005 -0
  39. package/index.js +5236 -566
  40. package/jest.config.js +11 -0
  41. package/man/clawdash.1 +276 -0
  42. package/package.json +54 -5
  43. package/schemas/plugin-manifest.json +128 -0
  44. package/scripts/release.js +595 -0
  45. package/src/alerts.js +693 -0
  46. package/src/auto-save.js +584 -0
  47. package/src/cache.js +390 -0
  48. package/src/checksum.js +146 -0
  49. package/src/cli/args.js +110 -0
  50. package/src/cli/export-schedule.js +423 -0
  51. package/src/cli/export-snapshot.js +138 -0
  52. package/src/cli/help.js +69 -0
  53. package/src/cli/import-snapshot.js +230 -0
  54. package/src/cli/index.js +14 -0
  55. package/src/cli/list-templates.js +69 -0
  56. package/src/cli/validate-config.js +76 -0
  57. package/src/cli/validate-plugin.js +187 -0
  58. package/src/cli/version.js +15 -0
  59. package/src/config-validator.js +586 -0
  60. package/src/config-watcher.js +401 -0
  61. package/src/config.js +684 -0
  62. package/src/container-detector.js +499 -0
  63. package/src/database.js +734 -0
  64. package/src/differential-render.js +327 -0
  65. package/src/errors.js +169 -0
  66. package/src/export-scheduler.js +581 -0
  67. package/src/gateway-manager.js +685 -0
  68. package/src/hints.js +371 -0
  69. package/src/loading-states.js +509 -0
  70. package/src/logger.js +185 -0
  71. package/src/memory-pressure.js +467 -0
  72. package/src/performance-monitor.js +374 -0
  73. package/src/plugin-errors.js +586 -0
  74. package/src/plugin-manifest-validator.js +219 -0
  75. package/src/plugin-reload.js +481 -0
  76. package/src/plugin-scaffold.js +1941 -0
  77. package/src/plugin-validator.js +284 -0
  78. package/src/retry.js +218 -0
  79. package/src/security.js +860 -0
  80. package/src/snapshot.js +372 -0
  81. package/src/splash.js +115 -0
  82. package/src/theme-selector.js +411 -0
  83. package/src/themes.js +874 -0
  84. package/src/transitions.js +534 -0
  85. package/src/types.d.ts +174 -0
  86. package/src/validation.js +926 -0
  87. package/src/web-server.js +885 -0
  88. package/src/widgets/builtin-widgets.js +1057 -0
  89. package/src/widgets/config-processor.js +401 -0
  90. package/src/widgets/dependency-resolver.js +596 -0
  91. package/src/widgets/index.js +79 -0
  92. package/src/widgets/plugin-api.js +845 -0
  93. package/src/widgets/widget-error-boundary.js +773 -0
  94. package/src/widgets/widget-error-isolation.js +551 -0
  95. package/src/widgets/widget-loader.js +1437 -0
  96. package/src/workers/system-worker.js +130 -0
  97. package/src/workers/worker-pool.js +633 -0
  98. package/tests/alerts.test.js +437 -0
  99. package/tests/auto-save.test.js +529 -0
  100. package/tests/cache.test.js +317 -0
  101. package/tests/cli.test.js +351 -0
  102. package/tests/command-palette.test.js +320 -0
  103. package/tests/config-processor.test.js +452 -0
  104. package/tests/config-validator.test.js +710 -0
  105. package/tests/config-watcher.test.js +594 -0
  106. package/tests/config.test.js +755 -0
  107. package/tests/database.test.js +438 -0
  108. package/tests/errors.test.js +189 -0
  109. package/tests/example-plugins.test.js +624 -0
  110. package/tests/integration.test.js +1321 -0
  111. package/tests/loading-states.test.js +300 -0
  112. package/tests/manifest-validation-on-load.test.js +671 -0
  113. package/tests/performance-monitor.test.js +190 -0
  114. package/tests/plugin-api-rate-limit.test.js +302 -0
  115. package/tests/plugin-errors.test.js +311 -0
  116. package/tests/plugin-lifecycle-e2e.test.js +1036 -0
  117. package/tests/plugin-reload.test.js +764 -0
  118. package/tests/rate-limiter.test.js +539 -0
  119. package/tests/retry.test.js +308 -0
  120. package/tests/security.test.js +411 -0
  121. package/tests/settings-modal.test.js +226 -0
  122. package/tests/theme-selector.test.js +57 -0
  123. package/tests/utils.js +242 -0
  124. package/tests/utils.test.js +317 -0
  125. package/tests/validate-plugin-cli.test.js +359 -0
  126. package/tests/validation.test.js +837 -0
  127. package/tests/web-server.test.js +646 -0
  128. package/tests/widget-arrange-mode.test.js +183 -0
  129. package/tests/widget-config-hot-reload.test.js +465 -0
  130. package/tests/widget-dependency.test.js +591 -0
  131. package/tests/widget-error-boundary.test.js +749 -0
  132. package/tests/widget-error-isolation.test.js +469 -0
  133. package/tests/widget-integration.test.js +1147 -0
  134. package/tests/widget-refresh-intervals.test.js +284 -0
  135. package/tests/worker-pool.test.js +522 -0
@@ -0,0 +1,885 @@
1
+ /**
2
+ * Web Server Module for Claw Dashboard
3
+ * Provides HTTP API for remote access to dashboard data
4
+ * Includes rate limiting and configurable CORS for security
5
+ */
6
+
7
+ import http from 'http';
8
+ import url from 'url';
9
+ import logger from './logger.js';
10
+ import config from './config.js';
11
+ import { ApiKeyAuth } from './security.js';
12
+
13
+ const { WEB, DASHBOARD_VERSION } = config;
14
+
15
+ /**
16
+ * Rate limiter for web server requests
17
+ * Tracks requests per IP address with sliding window
18
+ */
19
+ class WebRateLimiter {
20
+ constructor(options = {}) {
21
+ this.enabled = options.enabled ?? WEB.RATE_LIMIT.ENABLED;
22
+ this.windowMs = options.windowMs ?? WEB.RATE_LIMIT.WINDOW_MS;
23
+ this.maxRequests = options.maxRequests ?? WEB.RATE_LIMIT.MAX_REQUESTS;
24
+ this.trustProxy = options.trustProxy ?? WEB.RATE_LIMIT.TRUST_PROXY;
25
+ this.requests = new Map(); // ip -> [{ timestamp, count }]
26
+ this.blocked = new Map(); // ip -> unblockTime
27
+
28
+ // Start cleanup interval
29
+ this.cleanupInterval = setInterval(() => this.cleanup(), this.windowMs);
30
+ }
31
+
32
+ /**
33
+ * Get client IP from request
34
+ * @param {http.IncomingMessage} req - HTTP request
35
+ * @returns {string} Client IP address
36
+ */
37
+ getClientIp(req) {
38
+ // Check for X-Forwarded-For if behind proxy
39
+ if (this.trustProxy) {
40
+ const forwarded = req.headers['x-forwarded-for'];
41
+ if (forwarded) {
42
+ // X-Forwarded-For can be comma-separated, take the first (client)
43
+ return forwarded.split(',')[0].trim();
44
+ }
45
+ const realIp = req.headers['x-real-ip'];
46
+ if (realIp) {
47
+ return realIp;
48
+ }
49
+ }
50
+
51
+ // Fall back to connection remote address
52
+ return req.socket?.remoteAddress || req.connection?.remoteAddress || 'unknown';
53
+ }
54
+
55
+ /**
56
+ * Check if request is allowed or rate limited
57
+ * @param {http.IncomingMessage} req - HTTP request
58
+ * @returns {object} Result with allowed boolean and retryAfter
59
+ */
60
+ check(req) {
61
+ if (!this.enabled) {
62
+ return { allowed: true, remaining: this.maxRequests };
63
+ }
64
+
65
+ const ip = this.getClientIp(req);
66
+ const now = Date.now();
67
+
68
+ // Check if IP is currently blocked
69
+ const unblockTime = this.blocked.get(ip);
70
+ if (unblockTime && now < unblockTime) {
71
+ return {
72
+ allowed: false,
73
+ retryAfter: Math.ceil((unblockTime - now) / 1000),
74
+ ip
75
+ };
76
+ }
77
+
78
+ // Remove from blocked if time has passed
79
+ if (unblockTime && now >= unblockTime) {
80
+ this.blocked.delete(ip);
81
+ }
82
+
83
+ // Get or create request history for this IP
84
+ let history = this.requests.get(ip);
85
+ if (!history) {
86
+ history = [];
87
+ this.requests.set(ip, history);
88
+ }
89
+
90
+ // Filter to only requests within the current window
91
+ const windowStart = now - this.windowMs;
92
+ const validRequests = history.filter(ts => ts > windowStart);
93
+
94
+ // Update history with cleaned up timestamps
95
+ this.requests.set(ip, validRequests);
96
+
97
+ // Check if over limit
98
+ if (validRequests.length >= this.maxRequests) {
99
+ // Calculate retry after based on oldest request
100
+ const oldestRequest = validRequests[0];
101
+ const retryAfter = Math.ceil((oldestRequest + this.windowMs - now) / 1000);
102
+
103
+ // Block this IP temporarily
104
+ this.blocked.set(ip, now + this.windowMs);
105
+
106
+ logger.warn(`[RATE LIMIT] IP ${ip} blocked - exceeded ${this.maxRequests} requests in ${this.windowMs}ms`);
107
+
108
+ return {
109
+ allowed: false,
110
+ retryAfter: Math.max(1, retryAfter),
111
+ ip
112
+ };
113
+ }
114
+
115
+ return {
116
+ allowed: true,
117
+ remaining: this.maxRequests - validRequests.length,
118
+ ip
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Record a request for an IP
124
+ * @param {http.IncomingMessage} req - HTTP request
125
+ */
126
+ record(req) {
127
+ if (!this.enabled) return;
128
+
129
+ const ip = this.getClientIp(req);
130
+ const history = this.requests.get(ip) || [];
131
+ history.push(Date.now());
132
+ this.requests.set(ip, history);
133
+ }
134
+
135
+ /**
136
+ * Clean up old entries to prevent memory leaks
137
+ */
138
+ cleanup() {
139
+ const now = Date.now();
140
+ const windowStart = now - this.windowMs;
141
+
142
+ // Clean up request history
143
+ for (const [ip, history] of this.requests.entries()) {
144
+ const validRequests = history.filter(ts => ts > windowStart);
145
+ if (validRequests.length === 0) {
146
+ this.requests.delete(ip);
147
+ } else {
148
+ this.requests.set(ip, validRequests);
149
+ }
150
+ }
151
+
152
+ // Clean up blocked entries
153
+ for (const [ip, unblockTime] of this.blocked.entries()) {
154
+ if (now >= unblockTime) {
155
+ this.blocked.delete(ip);
156
+ }
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Get rate limit status for an IP
162
+ * @param {http.IncomingMessage} req - HTTP request
163
+ * @returns {object} Status with count, limit, remaining, resetTime
164
+ */
165
+ getStatus(req) {
166
+ const ip = this.getClientIp(req);
167
+
168
+ if (!this.enabled) {
169
+ return {
170
+ enabled: false,
171
+ limit: this.maxRequests,
172
+ remaining: this.maxRequests,
173
+ current: 0,
174
+ resetTime: null,
175
+ ip
176
+ };
177
+ }
178
+
179
+ const history = this.requests.get(ip) || [];
180
+ const now = Date.now();
181
+ const windowStart = now - this.windowMs;
182
+ const validRequests = history.filter(ts => ts > windowStart);
183
+
184
+ let resetTime = null;
185
+ if (validRequests.length > 0) {
186
+ const oldestRequest = Math.min(...validRequests);
187
+ resetTime = new Date(oldestRequest + this.windowMs).toISOString();
188
+ }
189
+
190
+ return {
191
+ enabled: true,
192
+ limit: this.maxRequests,
193
+ remaining: Math.max(0, this.maxRequests - validRequests.length),
194
+ current: validRequests.length,
195
+ resetTime,
196
+ ip
197
+ };
198
+ }
199
+
200
+ /**
201
+ * Stop the cleanup interval
202
+ */
203
+ stop() {
204
+ if (this.cleanupInterval) {
205
+ clearInterval(this.cleanupInterval);
206
+ this.cleanupInterval = null;
207
+ }
208
+ }
209
+ }
210
+
211
+ /**
212
+ * CORS configuration manager
213
+ * Handles CORS headers based on configuration
214
+ */
215
+ class CorsManager {
216
+ constructor(options = {}) {
217
+ // Support both old config (string) and new config (object/array)
218
+ this.allowedOrigins = options.allowedOrigins ?? WEB.CORS.ALLOWED_ORIGINS;
219
+ this.allowedMethods = options.allowedMethods ?? WEB.CORS.ALLOWED_METHODS;
220
+ this.allowedHeaders = options.allowedHeaders ?? WEB.CORS.ALLOWED_HEADERS;
221
+ this.credentials = options.credentials ?? WEB.CORS.CREDENTIALS;
222
+ this.maxAge = options.maxAge ?? WEB.CORS.MAX_AGE;
223
+ }
224
+
225
+ /**
226
+ * Check if an origin is allowed
227
+ * @param {string} origin - Request origin
228
+ * @returns {boolean} True if allowed
229
+ */
230
+ isOriginAllowed(origin) {
231
+ // Allow all origins with '*'
232
+ if (this.allowedOrigins === '*') {
233
+ return true;
234
+ }
235
+
236
+ // No origin header (e.g., direct curl request)
237
+ if (!origin) {
238
+ return true;
239
+ }
240
+
241
+ // If it's an array, check if origin is in the list
242
+ if (Array.isArray(this.allowedOrigins)) {
243
+ return this.allowedOrigins.some(allowed => {
244
+ if (allowed.includes('*')) {
245
+ // Support wildcard patterns like https://*.example.com
246
+ const pattern = allowed.replace(/\*/g, '.*');
247
+ return new RegExp(`^${pattern}$`).test(origin);
248
+ }
249
+ return allowed === origin;
250
+ });
251
+ }
252
+
253
+ // String comparison
254
+ return this.allowedOrigins === origin;
255
+ }
256
+
257
+ /**
258
+ * Get CORS headers for a request
259
+ * @param {http.IncomingMessage} req - HTTP request
260
+ * @returns {Object} CORS headers
261
+ */
262
+ getHeaders(req) {
263
+ const origin = req.headers.origin;
264
+ const headers = {
265
+ 'Access-Control-Allow-Methods': this.allowedMethods.join(', '),
266
+ 'Access-Control-Allow-Headers': this.allowedHeaders.join(', '),
267
+ 'Access-Control-Max-Age': this.maxAge.toString(),
268
+ 'Content-Type': 'application/json',
269
+ };
270
+
271
+ // Handle origin
272
+ if (this.allowedOrigins === '*') {
273
+ // When credentials are used with '*', browser rejects it for security
274
+ // So we mirror the request origin when credentials are enabled
275
+ if (this.credentials && origin) {
276
+ headers['Access-Control-Allow-Origin'] = origin;
277
+ headers['Access-Control-Allow-Credentials'] = 'true';
278
+ } else {
279
+ headers['Access-Control-Allow-Origin'] = '*';
280
+ }
281
+ } else if (this.isOriginAllowed(origin)) {
282
+ headers['Access-Control-Allow-Origin'] = origin || '*';
283
+ if (this.credentials) {
284
+ headers['Access-Control-Allow-Credentials'] = 'true';
285
+ }
286
+ }
287
+ // If origin is not allowed, don't include CORS headers (browser will block)
288
+
289
+ return headers;
290
+ }
291
+
292
+ /**
293
+ * Check if credentials should be allowed
294
+ * @returns {boolean} True if credentials are allowed
295
+ */
296
+ allowsCredentials() {
297
+ return this.credentials;
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Create CORS headers for HTTP responses (legacy function for backward compatibility)
303
+ * @returns {Object} CORS headers
304
+ * @deprecated Use CorsManager instead
305
+ */
306
+ function getCorsHeaders() {
307
+ const corsManager = new CorsManager();
308
+ return corsManager.getHeaders({ headers: {} });
309
+ }
310
+
311
+ /**
312
+ * Send JSON response
313
+ * @param {http.ServerResponse} res - HTTP response
314
+ * @param {number} statusCode - HTTP status code
315
+ * @param {Object} data - Response data
316
+ * @param {Object} headers - Additional headers
317
+ */
318
+ function sendJson(res, statusCode, data, headers = {}) {
319
+ res.writeHead(statusCode, { ...headers, 'Content-Type': 'application/json' });
320
+ res.end(JSON.stringify(data, null, 2));
321
+ }
322
+
323
+ /**
324
+ * Send error response
325
+ * @param {http.ServerResponse} res - HTTP response
326
+ * @param {number} statusCode - HTTP status code
327
+ * @param {string} message - Error message
328
+ * @param {Object} headers - Additional headers
329
+ * @param {Object} extra - Extra fields to include in response
330
+ */
331
+ function sendError(res, statusCode, message, headers = {}, extra = {}) {
332
+ sendJson(res, statusCode, { error: message, status: statusCode, ...extra }, headers);
333
+ }
334
+
335
+ /**
336
+ * Web Server class for exposing dashboard data via HTTP API
337
+ */
338
+ export class WebServer {
339
+ constructor(options = {}) {
340
+ this.port = options.port || WEB.DEFAULT_PORT;
341
+ this.host = options.host || WEB.HOST;
342
+ this.server = null;
343
+ this.dataProvider = null;
344
+ this.startTime = Date.now();
345
+ this.requestCount = 0;
346
+ this.errorCount = 0;
347
+
348
+ // Initialize rate limiter
349
+ this.rateLimiter = new WebRateLimiter({
350
+ enabled: options.rateLimit?.enabled ?? WEB.RATE_LIMIT.ENABLED,
351
+ windowMs: options.rateLimit?.windowMs ?? WEB.RATE_LIMIT.WINDOW_MS,
352
+ maxRequests: options.rateLimit?.maxRequests ?? WEB.RATE_LIMIT.MAX_REQUESTS,
353
+ trustProxy: options.rateLimit?.trustProxy ?? WEB.RATE_LIMIT.TRUST_PROXY,
354
+ });
355
+
356
+ // Initialize CORS manager
357
+ this.corsManager = new CorsManager({
358
+ allowedOrigins: options.corsOrigins ?? WEB.CORS.ALLOWED_ORIGINS,
359
+ allowedMethods: options.corsMethods ?? WEB.CORS.ALLOWED_METHODS,
360
+ allowedHeaders: options.corsHeaders ?? WEB.CORS.ALLOWED_HEADERS,
361
+ credentials: options.corsCredentials ?? WEB.CORS.CREDENTIALS,
362
+ maxAge: options.corsMaxAge ?? WEB.CORS.MAX_AGE,
363
+ });
364
+
365
+ // Initialize API key authentication
366
+ this.apiKeyAuth = new ApiKeyAuth({
367
+ enabled: options.auth?.enabled ?? WEB.AUTH.ENABLED,
368
+ headerName: options.auth?.headerName ?? WEB.AUTH.HEADER_NAME,
369
+ scheme: options.auth?.scheme ?? WEB.AUTH.SCHEME,
370
+ keyPrefix: options.auth?.keyPrefix ?? WEB.AUTH.KEY_PREFIX,
371
+ keyLength: options.auth?.keyLength ?? WEB.AUTH.KEY_LENGTH,
372
+ maxKeys: options.auth?.maxKeys ?? WEB.AUTH.MAX_KEYS,
373
+ });
374
+
375
+ // Expose auth management methods
376
+ this.generateApiKey = this.generateApiKey.bind(this);
377
+ this.revokeApiKey = this.revokeApiKey.bind(this);
378
+ this.listApiKeys = this.listApiKeys.bind(this);
379
+ }
380
+
381
+ /**
382
+ * Set the data provider function that will supply dashboard data
383
+ * @param {Function} provider - Function that returns dashboard data
384
+ */
385
+ setDataProvider(provider) {
386
+ this.dataProvider = provider;
387
+ }
388
+
389
+ /**
390
+ * Get health status
391
+ * @returns {Object} Health status
392
+ */
393
+ getHealth() {
394
+ const uptime = Date.now() - this.startTime;
395
+ return {
396
+ status: 'healthy',
397
+ version: DASHBOARD_VERSION,
398
+ uptime: uptime,
399
+ uptimeHuman: this.formatUptime(uptime),
400
+ timestamp: new Date().toISOString(),
401
+ };
402
+ }
403
+
404
+ /**
405
+ * Format uptime in human-readable format
406
+ * @param {number} ms - Milliseconds
407
+ * @returns {string} Formatted uptime
408
+ */
409
+ formatUptime(ms) {
410
+ const seconds = Math.floor(ms / 1000);
411
+ const minutes = Math.floor(seconds / 60);
412
+ const hours = Math.floor(minutes / 60);
413
+ const days = Math.floor(hours / 24);
414
+
415
+ if (days > 0) return `${days}d ${hours % 24}h ${minutes % 60}m`;
416
+ if (hours > 0) return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
417
+ if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
418
+ return `${seconds}s`;
419
+ }
420
+
421
+ /**
422
+ * Send rate limit response
423
+ * @param {http.ServerResponse} res - HTTP response
424
+ * @param {Object} rateLimitResult - Rate limit check result
425
+ */
426
+ sendRateLimitResponse(res, rateLimitResult) {
427
+ const headers = this.corsManager.getHeaders({ headers: {} });
428
+ headers['Retry-After'] = rateLimitResult.retryAfter.toString();
429
+ headers['X-RateLimit-Limit'] = this.rateLimiter.maxRequests.toString();
430
+ headers['X-RateLimit-Remaining'] = '0';
431
+ headers['X-RateLimit-Reset'] = (Date.now() + rateLimitResult.retryAfter * 1000).toString();
432
+
433
+ sendError(res, 429, 'Too many requests', headers, {
434
+ retryAfter: rateLimitResult.retryAfter,
435
+ });
436
+ }
437
+
438
+ /**
439
+ * Send CORS-related error (origin not allowed)
440
+ * @param {http.ServerResponse} res - HTTP response
441
+ */
442
+ sendCorsError(res) {
443
+ sendError(res, 403, 'Origin not allowed', {
444
+ 'Content-Type': 'application/json',
445
+ });
446
+ }
447
+
448
+ /**
449
+ * Add rate limit headers to response
450
+ * @param {http.ServerResponse} res - HTTP response
451
+ * @param {Object} rateLimitStatus - Rate limit status
452
+ */
453
+ addRateLimitHeaders(res, rateLimitStatus) {
454
+ res.setHeader('X-RateLimit-Limit', rateLimitStatus.limit.toString());
455
+ res.setHeader('X-RateLimit-Remaining', rateLimitStatus.remaining.toString());
456
+ if (rateLimitStatus.resetTime) {
457
+ res.setHeader('X-RateLimit-Reset', new Date(rateLimitStatus.resetTime).getTime().toString());
458
+ }
459
+ }
460
+
461
+ /**
462
+ * Send authentication error response
463
+ * @param {http.ServerResponse} res - HTTP response
464
+ * @param {Object} authResult - Authentication result from ApiKeyAuth
465
+ * @param {Object} headers - Additional headers
466
+ */
467
+ sendAuthError(res, authResult, headers = {}) {
468
+ const errorHeaders = { ...headers };
469
+
470
+ // Add retry-after header if blocked
471
+ if (authResult.retryAfter) {
472
+ errorHeaders['Retry-After'] = authResult.retryAfter.toString();
473
+ }
474
+
475
+ // Add WWW-Authenticate header for 401 responses
476
+ const authScheme = this.apiKeyAuth.scheme || 'Bearer';
477
+ errorHeaders['WWW-Authenticate'] = `${authScheme} realm="Claw Dashboard API"`;
478
+
479
+ const statusCode = authResult.code === 'AUTH_BLOCKED' ? 429 : 401;
480
+ const extra = authResult.retryAfter ? { retryAfter: authResult.retryAfter } : {};
481
+
482
+ sendError(res, statusCode, authResult.error, errorHeaders, { code: authResult.code, ...extra });
483
+ }
484
+
485
+ /**
486
+ * Generate a new API key
487
+ * @param {string} name - Human-readable name for the key
488
+ * @returns {Object} Key data including the full key (only shown once)
489
+ */
490
+ generateApiKey(name) {
491
+ return this.apiKeyAuth.generateKey(name);
492
+ }
493
+
494
+ /**
495
+ * Revoke an API key
496
+ * @param {string} keyId - The key ID to revoke
497
+ * @returns {boolean} True if key was found and revoked
498
+ */
499
+ revokeApiKey(keyId) {
500
+ const revoked = this.apiKeyAuth.revokeKey(keyId);
501
+ if (revoked) {
502
+ logger.info(`[AUTH] Revoked API key: ${keyId}`);
503
+ }
504
+ return revoked;
505
+ }
506
+
507
+ /**
508
+ * List all active API keys
509
+ * @returns {Array} List of key metadata (without actual keys)
510
+ */
511
+ listApiKeys() {
512
+ return this.apiKeyAuth.listKeys();
513
+ }
514
+
515
+ /**
516
+ * Check if authentication is enabled
517
+ * @returns {boolean} True if authentication is enabled
518
+ */
519
+ isAuthEnabled() {
520
+ return this.apiKeyAuth.isEnabled();
521
+ }
522
+
523
+ /**
524
+ * Enable authentication
525
+ */
526
+ enableAuth() {
527
+ this.apiKeyAuth.enable();
528
+ logger.info('[AUTH] Authentication enabled');
529
+ }
530
+
531
+ /**
532
+ * Disable authentication
533
+ */
534
+ disableAuth() {
535
+ this.apiKeyAuth.disable();
536
+ logger.info('[AUTH] Authentication disabled');
537
+ }
538
+
539
+ /**
540
+ * Handle incoming HTTP requests
541
+ * @param {http.IncomingMessage} req - HTTP request
542
+ * @param {http.ServerResponse} res - HTTP response
543
+ */
544
+ async handleRequest(req, res) {
545
+ this.requestCount++;
546
+ const parsedUrl = url.parse(req.url, true);
547
+ const pathname = parsedUrl.pathname;
548
+
549
+ // Get CORS headers
550
+ const corsHeaders = this.corsManager.getHeaders(req);
551
+
552
+ // Check if CORS origin is allowed
553
+ const origin = req.headers.origin;
554
+ if (origin && !this.corsManager.isOriginAllowed(origin)) {
555
+ this.errorCount++;
556
+ logger.warn(`[CORS] Rejected request from disallowed origin: ${origin}`);
557
+ this.sendCorsError(res);
558
+ return;
559
+ }
560
+
561
+ // Handle CORS preflight
562
+ if (req.method === 'OPTIONS') {
563
+ res.writeHead(200, corsHeaders);
564
+ res.end();
565
+ return;
566
+ }
567
+
568
+ // Check rate limit (skip for health endpoint to allow monitoring)
569
+ if (pathname !== WEB.ENDPOINTS.HEALTH) {
570
+ const rateLimitResult = this.rateLimiter.check(req);
571
+
572
+ if (!rateLimitResult.allowed) {
573
+ this.errorCount++;
574
+ this.sendRateLimitResponse(res, rateLimitResult);
575
+ return;
576
+ }
577
+
578
+ // Record this request
579
+ this.rateLimiter.record(req);
580
+
581
+ // Add rate limit headers to successful responses
582
+ const rateLimitStatus = this.rateLimiter.getStatus(req);
583
+ this.addRateLimitHeaders(res, rateLimitStatus);
584
+ }
585
+
586
+ // Check authentication (skip for health endpoint)
587
+ if (pathname !== WEB.ENDPOINTS.HEALTH) {
588
+ const clientIp = this.rateLimiter.getClientIp(req);
589
+ const authResult = this.apiKeyAuth.authenticate(req.headers, clientIp);
590
+
591
+ if (!authResult.authenticated) {
592
+ this.errorCount++;
593
+ logger.warn(`[AUTH] Failed authentication from ${clientIp}: ${authResult.error}`);
594
+ this.sendAuthError(res, authResult, corsHeaders);
595
+ return;
596
+ }
597
+
598
+ // Add authenticated key info to response headers
599
+ if (authResult.keyId) {
600
+ res.setHeader('X-Auth-Key-Id', authResult.keyId);
601
+ }
602
+ }
603
+
604
+ // Route requests
605
+ try {
606
+ switch (pathname) {
607
+ case WEB.ENDPOINTS.HEALTH:
608
+ this.handleHealth(req, res, corsHeaders);
609
+ break;
610
+ case WEB.ENDPOINTS.METRICS:
611
+ await this.handleMetrics(req, res, corsHeaders);
612
+ break;
613
+ case WEB.ENDPOINTS.SESSIONS:
614
+ await this.handleSessions(req, res, corsHeaders);
615
+ break;
616
+ case WEB.ENDPOINTS.AGENTS:
617
+ await this.handleAgents(req, res, corsHeaders);
618
+ break;
619
+ case WEB.ENDPOINTS.LOGS:
620
+ await this.handleLogs(req, res, corsHeaders);
621
+ break;
622
+ case WEB.ENDPOINTS.STATUS:
623
+ await this.handleStatus(req, res, corsHeaders);
624
+ break;
625
+ default:
626
+ sendError(res, 404, 'Not found', corsHeaders);
627
+ }
628
+ } catch (err) {
629
+ this.errorCount++;
630
+ logger.error(`Web server error: ${err.message}`);
631
+ sendError(res, 500, 'Internal server error', corsHeaders);
632
+ }
633
+ }
634
+
635
+ /**
636
+ * Handle health check endpoint
637
+ * @param {http.IncomingMessage} req - HTTP request
638
+ * @param {http.ServerResponse} res - HTTP response
639
+ * @param {Object} corsHeaders - CORS headers
640
+ */
641
+ handleHealth(req, res, corsHeaders) {
642
+ const health = this.getHealth();
643
+ // Add rate limit info to health endpoint
644
+ const rateLimitStatus = this.rateLimiter.getStatus(req);
645
+
646
+ sendJson(res, 200, {
647
+ ...health,
648
+ rateLimit: {
649
+ enabled: rateLimitStatus.enabled,
650
+ limit: rateLimitStatus.limit,
651
+ },
652
+ auth: {
653
+ enabled: this.apiKeyAuth.isEnabled(),
654
+ scheme: this.apiKeyAuth.scheme,
655
+ keyCount: this.apiKeyAuth.getKeyCount(),
656
+ },
657
+ }, corsHeaders);
658
+ }
659
+
660
+ /**
661
+ * Handle metrics endpoint
662
+ * @param {http.IncomingMessage} req - HTTP request
663
+ * @param {http.ServerResponse} res - HTTP response
664
+ * @param {Object} corsHeaders - CORS headers
665
+ */
666
+ async handleMetrics(req, res, corsHeaders) {
667
+ if (!this.dataProvider) {
668
+ sendError(res, 503, 'Data provider not available', corsHeaders);
669
+ return;
670
+ }
671
+
672
+ try {
673
+ const data = await this.dataProvider('metrics');
674
+ sendJson(res, 200, {
675
+ timestamp: new Date().toISOString(),
676
+ metrics: data || {},
677
+ }, corsHeaders);
678
+ } catch (err) {
679
+ logger.error(`Metrics error: ${err.message}`);
680
+ sendError(res, 500, 'Failed to fetch metrics', corsHeaders);
681
+ }
682
+ }
683
+
684
+ /**
685
+ * Handle sessions endpoint
686
+ * @param {http.IncomingMessage} req - HTTP request
687
+ * @param {http.ServerResponse} res - HTTP response
688
+ * @param {Object} corsHeaders - CORS headers
689
+ */
690
+ async handleSessions(req, res, corsHeaders) {
691
+ if (!this.dataProvider) {
692
+ sendError(res, 503, 'Data provider not available', corsHeaders);
693
+ return;
694
+ }
695
+
696
+ try {
697
+ const data = await this.dataProvider('sessions');
698
+ sendJson(res, 200, {
699
+ timestamp: new Date().toISOString(),
700
+ sessions: data || [],
701
+ count: data?.length || 0,
702
+ }, corsHeaders);
703
+ } catch (err) {
704
+ logger.error(`Sessions error: ${err.message}`);
705
+ sendError(res, 500, 'Failed to fetch sessions', corsHeaders);
706
+ }
707
+ }
708
+
709
+ /**
710
+ * Handle agents endpoint
711
+ * @param {http.IncomingMessage} req - HTTP request
712
+ * @param {http.ServerResponse} res - HTTP response
713
+ * @param {Object} corsHeaders - CORS headers
714
+ */
715
+ async handleAgents(req, res, corsHeaders) {
716
+ if (!this.dataProvider) {
717
+ sendError(res, 503, 'Data provider not available', corsHeaders);
718
+ return;
719
+ }
720
+
721
+ try {
722
+ const data = await this.dataProvider('agents');
723
+ sendJson(res, 200, {
724
+ timestamp: new Date().toISOString(),
725
+ agents: data || [],
726
+ count: data?.length || 0,
727
+ }, corsHeaders);
728
+ } catch (err) {
729
+ logger.error(`Agents error: ${err.message}`);
730
+ sendError(res, 500, 'Failed to fetch agents', corsHeaders);
731
+ }
732
+ }
733
+
734
+ /**
735
+ * Handle logs endpoint
736
+ * @param {http.IncomingMessage} req - HTTP request
737
+ * @param {http.ServerResponse} res - HTTP response
738
+ * @param {Object} corsHeaders - CORS headers
739
+ */
740
+ async handleLogs(req, res, corsHeaders) {
741
+ if (!this.dataProvider) {
742
+ sendError(res, 503, 'Data provider not available', corsHeaders);
743
+ return;
744
+ }
745
+
746
+ try {
747
+ const data = await this.dataProvider('logs');
748
+ sendJson(res, 200, {
749
+ timestamp: new Date().toISOString(),
750
+ logs: data || [],
751
+ count: data?.length || 0,
752
+ }, corsHeaders);
753
+ } catch (err) {
754
+ logger.error(`Logs error: ${err.message}`);
755
+ sendError(res, 500, 'Failed to fetch logs', corsHeaders);
756
+ }
757
+ }
758
+
759
+ /**
760
+ * Handle full status endpoint
761
+ * @param {http.IncomingMessage} req - HTTP request
762
+ * @param {http.ServerResponse} res - HTTP response
763
+ * @param {Object} corsHeaders - CORS headers
764
+ */
765
+ async handleStatus(req, res, corsHeaders) {
766
+ if (!this.dataProvider) {
767
+ sendError(res, 503, 'Data provider not available', corsHeaders);
768
+ return;
769
+ }
770
+
771
+ try {
772
+ const [metrics, sessions, agents, logs] = await Promise.all([
773
+ this.dataProvider('metrics'),
774
+ this.dataProvider('sessions'),
775
+ this.dataProvider('agents'),
776
+ this.dataProvider('logs'),
777
+ ]);
778
+
779
+ sendJson(res, 200, {
780
+ timestamp: new Date().toISOString(),
781
+ health: this.getHealth(),
782
+ metrics: metrics || {},
783
+ sessions: sessions || [],
784
+ agents: agents || [],
785
+ logs: logs || [],
786
+ summary: {
787
+ sessionCount: sessions?.length || 0,
788
+ agentCount: agents?.length || 0,
789
+ logCount: logs?.length || 0,
790
+ },
791
+ }, corsHeaders);
792
+ } catch (err) {
793
+ logger.error(`Status error: ${err.message}`);
794
+ sendError(res, 500, 'Failed to fetch status', corsHeaders);
795
+ }
796
+ }
797
+
798
+ /**
799
+ * Start the web server
800
+ * @returns {Promise<WebServer>} This instance for chaining
801
+ */
802
+ async start() {
803
+ return new Promise((resolve, reject) => {
804
+ this.server = http.createServer((req, res) => this.handleRequest(req, res));
805
+
806
+ this.server.on('error', (err) => {
807
+ logger.error(`Web server error: ${err.message}`);
808
+ reject(err);
809
+ });
810
+
811
+ this.server.listen(this.port, this.host, () => {
812
+ const rateLimitStatus = this.rateLimiter.enabled ? 'enabled' : 'disabled';
813
+ const corsStatus = this.corsManager.allowedOrigins === '*' ? 'allow-all' : 'restricted';
814
+ const authStatus = this.apiKeyAuth.isEnabled() ? 'enabled' : 'disabled';
815
+ logger.info(`Web server listening on http://${this.host}:${this.port} (rate-limit: ${rateLimitStatus}, cors: ${corsStatus}, auth: ${authStatus})`);
816
+ resolve(this);
817
+ });
818
+ });
819
+ }
820
+
821
+ /**
822
+ * Stop the web server
823
+ * @returns {Promise<void>}
824
+ */
825
+ async stop() {
826
+ if (!this.server) {
827
+ return;
828
+ }
829
+
830
+ // Stop rate limiter cleanup
831
+ this.rateLimiter.stop();
832
+
833
+ return new Promise((resolve) => {
834
+ this.server.close(() => {
835
+ logger.info('Web server stopped');
836
+ resolve();
837
+ });
838
+ });
839
+ }
840
+
841
+ /**
842
+ * Get server info
843
+ * @returns {Object} Server information
844
+ */
845
+ getInfo() {
846
+ const rateLimitStatus = this.rateLimiter.getStatus({ headers: {}, socket: {} });
847
+
848
+ return {
849
+ host: this.host,
850
+ port: this.port,
851
+ url: `http://${this.host}:${this.port}`,
852
+ endpoints: {
853
+ health: `${WEB.ENDPOINTS.HEALTH}`,
854
+ metrics: `${WEB.ENDPOINTS.METRICS}`,
855
+ sessions: `${WEB.ENDPOINTS.SESSIONS}`,
856
+ agents: `${WEB.ENDPOINTS.AGENTS}`,
857
+ logs: `${WEB.ENDPOINTS.LOGS}`,
858
+ status: `${WEB.ENDPOINTS.STATUS}`,
859
+ },
860
+ uptime: this.formatUptime(Date.now() - this.startTime),
861
+ requests: this.requestCount,
862
+ errors: this.errorCount,
863
+ security: {
864
+ rateLimit: {
865
+ enabled: this.rateLimiter.enabled,
866
+ windowMs: this.rateLimiter.windowMs,
867
+ maxRequests: this.rateLimiter.maxRequests,
868
+ },
869
+ cors: {
870
+ mode: this.corsManager.allowedOrigins === '*' ? 'allow-all' : 'restricted',
871
+ credentials: this.corsManager.credentials,
872
+ },
873
+ auth: {
874
+ enabled: this.apiKeyAuth.isEnabled(),
875
+ scheme: this.apiKeyAuth.scheme,
876
+ headerName: this.apiKeyAuth.headerName,
877
+ activeKeys: this.apiKeyAuth.getKeyCount(),
878
+ },
879
+ },
880
+ };
881
+ }
882
+ }
883
+
884
+ export default WebServer;
885
+ export { WebRateLimiter, CorsManager };