claw-dashboard 1.8.4 → 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 +5331 -512
  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,860 @@
1
+ /**
2
+ * Security utilities for file permissions and input sanitization
3
+ */
4
+
5
+ import fs from 'fs';
6
+ import path from 'path';
7
+ import crypto from 'crypto';
8
+ import { WEB } from './config.js';
9
+
10
+ /**
11
+ * Validate that a file path is safe (no null bytes, proper type)
12
+ * @param {string} filePath - Path to validate
13
+ * @returns {boolean} - True if path is valid
14
+ */
15
+ function isValidPath(filePath) {
16
+ if (!filePath || typeof filePath !== 'string') return false;
17
+ // Check for null bytes which can indicate injection attempts
18
+ if (filePath.includes('\0')) return false;
19
+ // Check for valid length
20
+ if (filePath.length === 0 || filePath.length > 4096) return false;
21
+ return true;
22
+ }
23
+
24
+ /**
25
+ * Check if path is a regular file (not symlink) before chmod
26
+ * @param {string} filePath - Path to check
27
+ * @returns {Promise<boolean>} - True if safe to chmod
28
+ */
29
+ async function isSafeToChmod(filePath) {
30
+ try {
31
+ const stats = await fs.promises.lstat(filePath);
32
+ // Only chmod regular files, not symlinks or directories
33
+ if (!stats.isFile() || stats.isSymbolicLink()) {
34
+ return false;
35
+ }
36
+ return true;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Set secure file permissions (0600 - owner read/write only)
44
+ * @param {string} filePath - Path to the file
45
+ * @returns {Promise<boolean>} - True if successful, false on failure
46
+ */
47
+ async function setSecurePermissions(filePath) {
48
+ if (!isValidPath(filePath)) {
49
+ console.error('Invalid file path provided for permission setting');
50
+ return false;
51
+ }
52
+
53
+ // Check if path is safe (not a symlink)
54
+ if (!await isSafeToChmod(filePath)) {
55
+ console.error(`Cannot set permissions on non-file path: ${filePath}`);
56
+ return false;
57
+ }
58
+
59
+ try {
60
+ await fs.promises.chmod(filePath, 0o600);
61
+ return true;
62
+ } catch (err) {
63
+ // Graceful fallback - log but don't crash
64
+ console.error(`Failed to set permissions on ${filePath}: ${err.message}`);
65
+ return false;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Check if path is a regular file (synchronous version)
71
+ * @param {string} filePath - Path to check
72
+ * @returns {boolean} - True if safe to chmod
73
+ */
74
+ function isSafeToChmodSync(filePath) {
75
+ try {
76
+ const stats = fs.lstatSync(filePath);
77
+ // Only chmod regular files, not symlinks or directories
78
+ if (!stats.isFile() || stats.isSymbolicLink()) {
79
+ return false;
80
+ }
81
+ return true;
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Set secure file permissions (synchronous version)
89
+ * @param {string} filePath - Path to the file
90
+ * @returns {boolean} - True if successful, false on failure
91
+ */
92
+ function setSecurePermissionsSync(filePath) {
93
+ if (!isValidPath(filePath)) {
94
+ console.error('Invalid file path provided for permission setting');
95
+ return false;
96
+ }
97
+
98
+ // Check if path is safe (not a symlink)
99
+ if (!isSafeToChmodSync(filePath)) {
100
+ console.error(`Cannot set permissions on non-file path: ${filePath}`);
101
+ return false;
102
+ }
103
+
104
+ try {
105
+ fs.chmodSync(filePath, 0o600);
106
+ return true;
107
+ } catch (err) {
108
+ // Graceful fallback - log but don't crash
109
+ console.error(`Failed to set permissions on ${filePath}: ${err.message}`);
110
+ return false;
111
+ }
112
+ }
113
+
114
+ // ============================================================================
115
+ // WIDGET CONFIG SANITIZATION
116
+ // ============================================================================
117
+
118
+ /**
119
+ * Widget configuration validator class
120
+ * Provides sanitization and validation for user-provided widget configs
121
+ */
122
+ class WidgetConfigValidator {
123
+ constructor(options = {}) {
124
+ this.maxStringLength = options.maxStringLength || 1000;
125
+ this.maxDepth = options.maxDepth || 10;
126
+ this.maxArrayLength = options.maxArrayLength || 100;
127
+ this.allowedTypes = options.allowedTypes || ['string', 'number', 'boolean', 'object', 'array', 'null'];
128
+ this.stripNullBytes = options.stripNullBytes !== false;
129
+ this.maxKeyLength = options.maxKeyLength || 100;
130
+ }
131
+
132
+ /**
133
+ * Validate and sanitize a widget configuration
134
+ * @param {*} config - Raw configuration object
135
+ * @param {Object} schema - Optional schema to validate against
136
+ * @returns {Object} Sanitized configuration
137
+ */
138
+ validate(config, schema = null) {
139
+ if (config === null || config === undefined) {
140
+ return {};
141
+ }
142
+
143
+ if (typeof config !== 'object') {
144
+ throw new Error('Widget config must be an object');
145
+ }
146
+
147
+ return this._sanitizeValue(config, 0, schema);
148
+ }
149
+
150
+ /**
151
+ * Internal sanitization method with depth tracking
152
+ * @private
153
+ */
154
+ _sanitizeValue(value, depth, schema) {
155
+ if (depth > this.maxDepth) {
156
+ throw new Error(`Configuration exceeds maximum depth of ${this.maxDepth}`);
157
+ }
158
+
159
+ if (value === null) {
160
+ return null;
161
+ }
162
+
163
+ if (value === undefined) {
164
+ return undefined;
165
+ }
166
+
167
+ const type = Array.isArray(value) ? 'array' : typeof value;
168
+
169
+ // Type whitelist check
170
+ if (!this.allowedTypes.includes(type)) {
171
+ throw new Error(`Invalid type: ${type}`);
172
+ }
173
+
174
+ if (type === 'string') {
175
+ return this._sanitizeString(value);
176
+ }
177
+
178
+ if (type === 'number') {
179
+ return this._sanitizeNumber(value);
180
+ }
181
+
182
+ if (type === 'boolean') {
183
+ return value;
184
+ }
185
+
186
+ if (type === 'array') {
187
+ return this._sanitizeArray(value, depth, schema);
188
+ }
189
+
190
+ if (type === 'object') {
191
+ return this._sanitizeObject(value, depth, schema);
192
+ }
193
+
194
+ return value;
195
+ }
196
+
197
+ /**
198
+ * Sanitize a string value
199
+ * @private
200
+ */
201
+ _sanitizeString(str) {
202
+ if (typeof str !== 'string') {
203
+ return String(str);
204
+ }
205
+
206
+ // Null byte stripping
207
+ if (this.stripNullBytes) {
208
+ str = str.replace(/\0/g, '');
209
+ }
210
+
211
+ // Length limit
212
+ if (str.length > this.maxStringLength) {
213
+ str = str.substring(0, this.maxStringLength);
214
+ }
215
+
216
+ return str;
217
+ }
218
+
219
+ /**
220
+ * Sanitize a number value
221
+ * @private
222
+ */
223
+ _sanitizeNumber(num) {
224
+ if (typeof num !== 'number') {
225
+ return NaN;
226
+ }
227
+
228
+ // Reject NaN and Infinity for safety
229
+ if (Number.isNaN(num) || !Number.isFinite(num)) {
230
+ return 0;
231
+ }
232
+
233
+ return num;
234
+ }
235
+
236
+ /**
237
+ * Sanitize an array
238
+ * @private
239
+ */
240
+ _sanitizeArray(arr, depth, schema) {
241
+ if (!Array.isArray(arr)) {
242
+ return [];
243
+ }
244
+
245
+ // Length limit
246
+ if (arr.length > this.maxArrayLength) {
247
+ arr = arr.slice(0, this.maxArrayLength);
248
+ }
249
+
250
+ // Get array item schema if available
251
+ const itemSchema = schema?.items;
252
+
253
+ return arr.map((item, index) => {
254
+ try {
255
+ return this._sanitizeValue(item, depth + 1, itemSchema);
256
+ } catch (err) {
257
+ // Skip invalid array items
258
+ return null;
259
+ }
260
+ });
261
+ }
262
+
263
+ /**
264
+ * Sanitize an object
265
+ * @private
266
+ */
267
+ _sanitizeObject(obj, depth, schema) {
268
+ if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
269
+ return {};
270
+ }
271
+
272
+ const sanitized = {};
273
+ const properties = schema?.properties || {};
274
+ const allowedKeys = schema ? new Set(Object.keys(properties)) : null;
275
+
276
+ for (const key of Object.keys(obj)) {
277
+ // Key length limit
278
+ if (key.length > this.maxKeyLength) {
279
+ continue;
280
+ }
281
+
282
+ // Skip non-whitelisted keys if schema provided
283
+ if (allowedKeys && !allowedKeys.has(key)) {
284
+ continue;
285
+ }
286
+
287
+ try {
288
+ const keySchema = properties?.[key];
289
+ sanitized[key] = this._sanitizeValue(obj[key], depth + 1, keySchema);
290
+ } catch (err) {
291
+ // Use default or skip invalid values
292
+ const defaultValue = properties?.[key]?.default;
293
+ sanitized[key] = defaultValue !== undefined ? defaultValue : null;
294
+ }
295
+ }
296
+
297
+ return sanitized;
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Sanitize widget configuration (convenience function)
303
+ * @param {*} config - Raw configuration
304
+ * @param {Object} schema - Optional schema
305
+ * @returns {Object} Sanitized config
306
+ */
307
+ function sanitizeWidgetConfig(config, schema = null) {
308
+ const validator = new WidgetConfigValidator();
309
+ return validator.validate(config, schema);
310
+ }
311
+
312
+ /**
313
+ * Validate widget configuration against a schema
314
+ * @param {*} config - Configuration to validate
315
+ * @param {Object} schema - Validation schema
316
+ * @returns {Object} Validation result { valid: boolean, errors: string[] }
317
+ */
318
+ function validateWidgetConfig(config, schema) {
319
+ const errors = [];
320
+ const validator = new WidgetConfigValidator();
321
+
322
+ try {
323
+ validator.validate(config, schema);
324
+ return { valid: true, errors: [] };
325
+ } catch (err) {
326
+ errors.push(err.message);
327
+ return { valid: false, errors };
328
+ }
329
+ }
330
+
331
+ // ============================================================================
332
+ // PLUGIN PATH VALIDATION
333
+ // ============================================================================
334
+
335
+ /**
336
+ * Validate a plugin path to prevent path traversal attacks
337
+ * Ensures the resolved path stays within allowed base directories
338
+ *
339
+ * @param {string} inputPath - The path to validate (can be relative or absolute)
340
+ * @param {Object} options - Validation options
341
+ * @param {string[]} options.allowedDirs - Array of allowed base directories (resolved paths must be within these)
342
+ * @param {boolean} options.allowAbsolute - Whether to allow absolute paths (default: false)
343
+ * @param {boolean} options.mustExist - Whether the path must exist (default: false)
344
+ * @param {string} options.expectedType - Expected file type: 'file', 'directory', or null for any
345
+ * @returns {Object} Validation result: { valid: boolean, path: string|null, error: string|null }
346
+ */
347
+ function validatePluginPath(inputPath, options = {}) {
348
+ const { allowedDirs = [], allowAbsolute = false, mustExist = false, expectedType = null } = options;
349
+
350
+ // Basic validation
351
+ if (!inputPath || typeof inputPath !== 'string') {
352
+ return { valid: false, path: null, error: 'Path must be a non-empty string' };
353
+ }
354
+
355
+ // Check for null bytes
356
+ if (inputPath.includes('\0')) {
357
+ return { valid: false, path: null, error: 'Path contains null bytes' };
358
+ }
359
+
360
+ // Reject absolute paths unless explicitly allowed
361
+ if (path.isAbsolute(inputPath) && !allowAbsolute) {
362
+ return { valid: false, path: null, error: 'Absolute paths are not allowed' };
363
+ }
364
+
365
+ // Check for obvious path traversal attempts in the input
366
+ const normalizedInput = path.normalize(inputPath);
367
+
368
+ // Reject any path that:
369
+ // 1. Starts with .. (going up from base)
370
+ // 2. Contains ../ anywhere
371
+ // 3. After normalizing, would escape the base directory
372
+ if (normalizedInput.startsWith('..')) {
373
+ return { valid: false, path: null, error: 'Path traversal detected' };
374
+ }
375
+
376
+ // Check for ../ in the original input before normalization
377
+ if (inputPath.includes('../') || inputPath.includes('..\\')) {
378
+ return { valid: false, path: null, error: 'Path traversal detected' };
379
+ }
380
+
381
+ // Validate directory/file name characters
382
+ // Allow: alphanumeric, hyphens, underscores, dots (for extensions)
383
+ const parts = inputPath.split(path.sep).filter(part => part.length > 0);
384
+ for (const part of parts) {
385
+ // Skip if it's just '.' or '..'
386
+ if (part === '.' || part === '..') {
387
+ continue;
388
+ }
389
+
390
+ // Check for invalid characters - allow alphanumerics, hyphens, underscores, dots
391
+ if (!/^[a-zA-Z0-9._-]+$/.test(part)) {
392
+ return { valid: false, path: null, error: `Invalid characters in path component: ${part}` };
393
+ }
394
+
395
+ // Check for hidden files/directories (starting with .)
396
+ if (part.startsWith('.') && part !== '.' && part !== '..') {
397
+ // Allow specific hidden files like .gitkeep but not arbitrary ones
398
+ const allowedHidden = ['.gitkeep', '.gitignore', '.npmignore'];
399
+ if (!allowedHidden.includes(part)) {
400
+ return { valid: false, path: null, error: `Hidden files/directories are not allowed: ${part}` };
401
+ }
402
+ }
403
+ }
404
+
405
+ // Resolve the full path if allowedDirs provided
406
+ let resolvedPath;
407
+ try {
408
+ // Resolve relative to first allowed dir, or just normalize
409
+ if (allowedDirs.length > 0) {
410
+ // Use first allowed dir as base for relative paths
411
+ const baseDir = allowedDirs[0];
412
+ resolvedPath = path.resolve(baseDir, inputPath);
413
+ } else {
414
+ resolvedPath = path.resolve(inputPath);
415
+ }
416
+ } catch (err) {
417
+ return { valid: false, path: null, error: `Failed to resolve path: ${err.message}` };
418
+ }
419
+
420
+ // Verify the resolved path is within allowed directories
421
+ if (allowedDirs.length > 0) {
422
+ const isWithinAllowed = allowedDirs.some(allowedDir => {
423
+ // Ensure allowedDir ends with separator for proper prefix check
424
+ const normalizedAllowed = allowedDir.endsWith(path.sep) ? allowedDir : allowedDir + path.sep;
425
+ const normalizedResolved = resolvedPath.endsWith(path.sep) ? resolvedPath : resolvedPath + path.sep;
426
+ return normalizedResolved.startsWith(normalizedAllowed);
427
+ });
428
+
429
+ if (!isWithinAllowed) {
430
+ return { valid: false, path: null, error: 'Path is outside allowed directories' };
431
+ }
432
+ }
433
+
434
+ // Check if path must exist
435
+ if (mustExist) {
436
+ try {
437
+ const stats = fs.statSync(resolvedPath);
438
+
439
+ if (expectedType === 'file' && !stats.isFile()) {
440
+ return { valid: false, path: null, error: 'Path exists but is not a file' };
441
+ }
442
+
443
+ if (expectedType === 'directory' && !stats.isDirectory()) {
444
+ return { valid: false, path: null, error: 'Path exists but is not a directory' };
445
+ }
446
+ } catch (err) {
447
+ return { valid: false, path: null, error: `Path does not exist: ${resolvedPath}` };
448
+ }
449
+ }
450
+
451
+ // Verify the resolved path didn't escape via symlinks
452
+ try {
453
+ const realPath = fs.realpathSync(resolvedPath);
454
+ if (allowedDirs.length > 0) {
455
+ // Resolve allowedDirs to their real paths for proper comparison
456
+ // (e.g., on macOS /var is a symlink to /private/var)
457
+ const realAllowedDirs = allowedDirs.map(allowedDir => {
458
+ try {
459
+ return fs.realpathSync(allowedDir);
460
+ } catch {
461
+ return allowedDir; // If realpath fails, use original path
462
+ }
463
+ });
464
+
465
+ const isRealPathWithinAllowed = realAllowedDirs.some(realAllowedDir => {
466
+ const normalizedAllowed = realAllowedDir.endsWith(path.sep) ? realAllowedDir : realAllowedDir + path.sep;
467
+ const normalizedReal = realPath.endsWith(path.sep) ? realPath : realPath + path.sep;
468
+ return normalizedReal.startsWith(normalizedAllowed);
469
+ });
470
+
471
+ if (!isRealPathWithinAllowed) {
472
+ return { valid: false, path: null, error: 'Path resolves outside allowed directories via symlink' };
473
+ }
474
+ }
475
+ } catch (err) {
476
+ // realpathSync fails if path doesn't exist - that's ok if mustExist is false
477
+ if (mustExist) {
478
+ return { valid: false, path: null, error: `Failed to resolve real path: ${err.message}` };
479
+ }
480
+ }
481
+
482
+ return { valid: true, path: resolvedPath, error: null };
483
+ }
484
+
485
+ /**
486
+ * Validate a plugin directory name
487
+ * Ensures the name is safe for use as a directory name
488
+ *
489
+ * @param {string} name - Directory name to validate
490
+ * @returns {Object} Validation result: { valid: boolean, error: string|null }
491
+ */
492
+ function validatePluginName(name) {
493
+ if (!name || typeof name !== 'string') {
494
+ return { valid: false, error: 'Plugin name must be a non-empty string' };
495
+ }
496
+
497
+ // Trim whitespace
498
+ const trimmed = name.trim();
499
+
500
+ if (trimmed.length === 0) {
501
+ return { valid: false, error: 'Plugin name cannot be empty' };
502
+ }
503
+
504
+ if (trimmed.length > 100) {
505
+ return { valid: false, error: 'Plugin name too long (max 100 characters)' };
506
+ }
507
+
508
+ // Reserved names that could cause issues - check these first
509
+ const reservedNames = ['node_modules', 'package.json', 'package-lock.json', '.git', '.hg', '.svn'];
510
+ if (reservedNames.includes(trimmed.toLowerCase())) {
511
+ return { valid: false, error: `Plugin name '${trimmed}' is reserved` };
512
+ }
513
+
514
+ // Allow: alphanumeric, hyphens, underscores
515
+ // Must start with alphanumeric
516
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(trimmed)) {
517
+ return { valid: false, error: 'Plugin name must contain only alphanumeric characters, hyphens, and underscores, and must start with alphanumeric' };
518
+ }
519
+
520
+ return { valid: true, error: null };
521
+ }
522
+
523
+ // ============================================================================
524
+ // API KEY AUTHENTICATION
525
+ // ============================================================================
526
+
527
+ /**
528
+ * API Key Authentication Manager
529
+ * Handles API key generation, validation, and revocation for web server security
530
+ */
531
+ class ApiKeyAuth {
532
+ constructor(options = {}) {
533
+ this.keys = new Map(); // key -> { name, createdAt, lastUsed, usageCount }
534
+ this.revokedKeys = new Set(); // Set of revoked key hashes
535
+ this.failedAttempts = new Map(); // ip -> { count, firstAttempt, blockedUntil }
536
+
537
+ // Configuration
538
+ this.enabled = options.enabled ?? WEB.AUTH.ENABLED;
539
+ this.headerName = options.headerName ?? WEB.AUTH.HEADER_NAME;
540
+ this.scheme = options.scheme ?? WEB.AUTH.SCHEME;
541
+ this.keyPrefix = options.keyPrefix ?? WEB.AUTH.KEY_PREFIX;
542
+ this.keyLength = options.keyLength ?? WEB.AUTH.KEY_LENGTH;
543
+ this.maxKeys = options.maxKeys ?? WEB.AUTH.MAX_KEYS;
544
+ this.maxFailedAttempts = options.maxFailedAttempts ?? 5;
545
+ this.blockDurationMs = options.blockDurationMs ?? 60000;
546
+
547
+ // Regex for valid key format
548
+ const prefix = this.keyPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
549
+ this.keyPattern = new RegExp(`^${prefix}[a-zA-Z0-9]{${this.keyLength}}$`);
550
+ }
551
+
552
+ /**
553
+ * Generate a cryptographically secure API key
554
+ * @param {string} name - Human-readable name for the key
555
+ * @returns {Object} { key, id, name, createdAt } - Returns the full key (only shown once)
556
+ */
557
+ generateKey(name) {
558
+ if (!name || typeof name !== 'string') {
559
+ throw new Error('API key name is required');
560
+ }
561
+
562
+ if (name.length < WEB.AUTH.KEY_NAME_MIN_LENGTH || name.length > WEB.AUTH.KEY_NAME_MAX_LENGTH) {
563
+ throw new Error(`Key name must be between ${WEB.AUTH.KEY_NAME_MIN_LENGTH} and ${WEB.AUTH.KEY_NAME_MAX_LENGTH} characters`);
564
+ }
565
+
566
+ if (this.keys.size >= this.maxKeys) {
567
+ throw new Error(`Maximum number of API keys (${this.maxKeys}) reached`);
568
+ }
569
+
570
+ // Generate cryptographically secure random key
571
+ const randomBytes = crypto.randomBytes(Math.ceil(this.keyLength / 2));
572
+ const randomPart = randomBytes.toString('hex').slice(0, this.keyLength);
573
+ const key = `${this.keyPrefix}${randomPart}`;
574
+
575
+ const keyData = {
576
+ id: crypto.randomUUID(),
577
+ name: name.trim(),
578
+ createdAt: new Date().toISOString(),
579
+ lastUsed: null,
580
+ usageCount: 0,
581
+ keyHash: this._hashKey(key),
582
+ };
583
+
584
+ this.keys.set(key, keyData);
585
+
586
+ return {
587
+ key, // Full key - only returned once
588
+ id: keyData.id,
589
+ name: keyData.name,
590
+ createdAt: keyData.createdAt,
591
+ };
592
+ }
593
+
594
+ /**
595
+ * Hash a key for secure storage/comparison
596
+ * @private
597
+ * @param {string} key - The API key
598
+ * @returns {string} SHA-256 hash of the key
599
+ */
600
+ _hashKey(key) {
601
+ return crypto.createHash('sha256').update(key).digest('hex');
602
+ }
603
+
604
+ /**
605
+ * Validate an API key format without checking existence
606
+ * @param {string} key - The API key to validate
607
+ * @returns {boolean} True if format is valid
608
+ */
609
+ isValidKeyFormat(key) {
610
+ if (!key || typeof key !== 'string') {
611
+ return false;
612
+ }
613
+ return this.keyPattern.test(key);
614
+ }
615
+
616
+ /**
617
+ * Check if an IP is currently blocked due to failed attempts
618
+ * @param {string} ip - Client IP address
619
+ * @returns {Object} { blocked: boolean, retryAfter?: number }
620
+ */
621
+ isBlocked(ip) {
622
+ if (!ip) return { blocked: false };
623
+
624
+ const attemptData = this.failedAttempts.get(ip);
625
+ if (!attemptData) return { blocked: false };
626
+
627
+ const now = Date.now();
628
+ if (attemptData.blockedUntil && now < attemptData.blockedUntil) {
629
+ return {
630
+ blocked: true,
631
+ retryAfter: Math.ceil((attemptData.blockedUntil - now) / 1000),
632
+ };
633
+ }
634
+
635
+ // Unblock if time has passed
636
+ if (attemptData.blockedUntil && now >= attemptData.blockedUntil) {
637
+ this.failedAttempts.delete(ip);
638
+ }
639
+
640
+ return { blocked: false };
641
+ }
642
+
643
+ /**
644
+ * Record a failed authentication attempt
645
+ * @private
646
+ * @param {string} ip - Client IP address
647
+ */
648
+ _recordFailedAttempt(ip) {
649
+ if (!ip) return;
650
+
651
+ const now = Date.now();
652
+ let attemptData = this.failedAttempts.get(ip);
653
+
654
+ if (!attemptData) {
655
+ attemptData = { count: 0, firstAttempt: now, blockedUntil: null };
656
+ }
657
+
658
+ attemptData.count++;
659
+
660
+ // Block IP if max attempts exceeded
661
+ if (attemptData.count >= this.maxFailedAttempts) {
662
+ attemptData.blockedUntil = now + this.blockDurationMs;
663
+ }
664
+
665
+ this.failedAttempts.set(ip, attemptData);
666
+ }
667
+
668
+ /**
669
+ * Clear failed attempts for an IP (after successful auth)
670
+ * @private
671
+ * @param {string} ip - Client IP address
672
+ */
673
+ _clearFailedAttempts(ip) {
674
+ if (ip) {
675
+ this.failedAttempts.delete(ip);
676
+ }
677
+ }
678
+
679
+ /**
680
+ * Extract API key from request headers
681
+ * @param {Object} headers - HTTP request headers
682
+ * @returns {string|null} Extracted API key or null
683
+ */
684
+ extractKey(headers) {
685
+ if (!headers || typeof headers !== 'object') {
686
+ return null;
687
+ }
688
+
689
+ // Case-insensitive header lookup
690
+ const headerNameLower = this.headerName.toLowerCase();
691
+ const authHeader = Object.entries(headers).find(
692
+ ([key]) => key.toLowerCase() === headerNameLower
693
+ )?.[1];
694
+
695
+ if (!authHeader) return null;
696
+
697
+ // Handle scheme-based auth (e.g., "Bearer cd_abc123...")
698
+ if (this.scheme) {
699
+ const schemeLower = this.scheme.toLowerCase();
700
+ const authLower = authHeader.toLowerCase();
701
+
702
+ if (authLower.startsWith(`${schemeLower} `)) {
703
+ return authHeader.slice(this.scheme.length + 1).trim();
704
+ }
705
+ }
706
+
707
+ // Return header value as-is (for x-api-key style headers)
708
+ return authHeader;
709
+ }
710
+
711
+ /**
712
+ * Authenticate a request
713
+ * @param {Object} headers - HTTP request headers
714
+ * @param {string} ip - Client IP address
715
+ * @returns {Object} Authentication result { authenticated: boolean, keyId?: string, error?: string }
716
+ */
717
+ authenticate(headers, ip) {
718
+ // Skip authentication if disabled
719
+ if (!this.enabled) {
720
+ return { authenticated: true };
721
+ }
722
+
723
+ // Check if IP is blocked
724
+ const blockStatus = this.isBlocked(ip);
725
+ if (blockStatus.blocked) {
726
+ return {
727
+ authenticated: false,
728
+ error: `Too many failed attempts. Retry after ${blockStatus.retryAfter} seconds`,
729
+ code: 'AUTH_BLOCKED',
730
+ retryAfter: blockStatus.retryAfter,
731
+ };
732
+ }
733
+
734
+ // Extract key from headers
735
+ const key = this.extractKey(headers);
736
+
737
+ if (!key) {
738
+ this._recordFailedAttempt(ip);
739
+ return {
740
+ authenticated: false,
741
+ error: 'Authentication required. Provide API key in header',
742
+ code: 'AUTH_REQUIRED',
743
+ };
744
+ }
745
+
746
+ // Validate key format
747
+ if (!this.isValidKeyFormat(key)) {
748
+ this._recordFailedAttempt(ip);
749
+ return {
750
+ authenticated: false,
751
+ error: 'Invalid API key format',
752
+ code: 'AUTH_INVALID_FORMAT',
753
+ };
754
+ }
755
+
756
+ // Check if key exists
757
+ const keyData = this.keys.get(key);
758
+ if (!keyData) {
759
+ this._recordFailedAttempt(ip);
760
+ return {
761
+ authenticated: false,
762
+ error: 'Invalid API key',
763
+ code: 'AUTH_INVALID_KEY',
764
+ };
765
+ }
766
+
767
+ // Check if key is revoked
768
+ if (this.revokedKeys.has(keyData.keyHash)) {
769
+ this._recordFailedAttempt(ip);
770
+ return {
771
+ authenticated: false,
772
+ error: 'API key has been revoked',
773
+ code: 'AUTH_REVOKED',
774
+ };
775
+ }
776
+
777
+ // Successful authentication
778
+ this._clearFailedAttempts(ip);
779
+ keyData.lastUsed = new Date().toISOString();
780
+ keyData.usageCount++;
781
+
782
+ return {
783
+ authenticated: true,
784
+ keyId: keyData.id,
785
+ keyName: keyData.name,
786
+ };
787
+ }
788
+
789
+ /**
790
+ * Revoke an API key
791
+ * @param {string} keyId - The key ID to revoke
792
+ * @returns {boolean} True if key was found and revoked
793
+ */
794
+ revokeKey(keyId) {
795
+ for (const [key, data] of this.keys.entries()) {
796
+ if (data.id === keyId) {
797
+ this.revokedKeys.add(data.keyHash);
798
+ this.keys.delete(key);
799
+ return true;
800
+ }
801
+ }
802
+ return false;
803
+ }
804
+
805
+ /**
806
+ * List all active API keys (without exposing the actual keys)
807
+ * @returns {Array} List of key metadata
808
+ */
809
+ listKeys() {
810
+ return Array.from(this.keys.values()).map(data => ({
811
+ id: data.id,
812
+ name: data.name,
813
+ createdAt: data.createdAt,
814
+ lastUsed: data.lastUsed,
815
+ usageCount: data.usageCount,
816
+ }));
817
+ }
818
+
819
+ /**
820
+ * Get the number of active keys
821
+ * @returns {number} Number of active API keys
822
+ */
823
+ getKeyCount() {
824
+ return this.keys.size;
825
+ }
826
+
827
+ /**
828
+ * Check if authentication is enabled
829
+ * @returns {boolean} True if authentication is enabled
830
+ */
831
+ isEnabled() {
832
+ return this.enabled;
833
+ }
834
+
835
+ /**
836
+ * Enable authentication
837
+ */
838
+ enable() {
839
+ this.enabled = true;
840
+ }
841
+
842
+ /**
843
+ * Disable authentication
844
+ */
845
+ disable() {
846
+ this.enabled = false;
847
+ }
848
+
849
+ /**
850
+ * Clear all API keys and failed attempts
851
+ */
852
+ clear() {
853
+ this.keys.clear();
854
+ this.revokedKeys.clear();
855
+ this.failedAttempts.clear();
856
+ }
857
+ }
858
+
859
+ export { setSecurePermissions, setSecurePermissionsSync, isValidPath, isSafeToChmod, isSafeToChmodSync, sanitizeWidgetConfig, validateWidgetConfig, WidgetConfigValidator, validatePluginPath, validatePluginName, ApiKeyAuth };
860
+ export default { setSecurePermissions, setSecurePermissionsSync, isValidPath, isSafeToChmod, isSafeToChmodSync, sanitizeWidgetConfig, validateWidgetConfig, WidgetConfigValidator, validatePluginPath, validatePluginName, ApiKeyAuth };