thatcher 1.0.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.
Files changed (221) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +398 -0
  3. package/package.json +73 -0
  4. package/src/adapters/google-auth.js +148 -0
  5. package/src/adapters/google-drive.js +209 -0
  6. package/src/app/api/[entity]/[[...path]]/route.js +33 -0
  7. package/src/app/api/audit/dashboard/route.js +77 -0
  8. package/src/app/api/audit/logs/route.js +46 -0
  9. package/src/app/api/audit/permissions/[id]/route.js +37 -0
  10. package/src/app/api/audit/permissions/route.js +93 -0
  11. package/src/app/api/audit/permissions/stats/route.js +29 -0
  12. package/src/app/api/audit/route.js +79 -0
  13. package/src/app/api/audit/stats/route.js +18 -0
  14. package/src/app/api/auth/google/callback/route.js +94 -0
  15. package/src/app/api/auth/google/route.js +58 -0
  16. package/src/app/api/auth/login/route.js +121 -0
  17. package/src/app/api/auth/logout/route.js +52 -0
  18. package/src/app/api/auth/me/route.js +16 -0
  19. package/src/app/api/auth/mwr-bridge/route.js +77 -0
  20. package/src/app/api/auth/password-reset/route.js +65 -0
  21. package/src/app/api/cron/trigger/route.js +58 -0
  22. package/src/app/api/csrf-token/route.js +8 -0
  23. package/src/app/api/debug/config/route.js +22 -0
  24. package/src/app/api/debug/hooks/route.js +16 -0
  25. package/src/app/api/debug/plugins/route.js +20 -0
  26. package/src/app/api/debug/sqlite/route.js +21 -0
  27. package/src/app/api/debug/sync/route.js +29 -0
  28. package/src/app/api/debug/workflow/route.js +20 -0
  29. package/src/app/api/domains/[domain]/route.js +26 -0
  30. package/src/app/api/domains/route.js +21 -0
  31. package/src/app/api/email/allocate/batch/route.js +106 -0
  32. package/src/app/api/email/allocate/route.js +141 -0
  33. package/src/app/api/email/receive/route.js +158 -0
  34. package/src/app/api/email/route.js +3 -0
  35. package/src/app/api/email/send/route.js +77 -0
  36. package/src/app/api/email/unallocated/route.js +47 -0
  37. package/src/app/api/files/[id]/route.js +38 -0
  38. package/src/app/api/health/route.js +95 -0
  39. package/src/app/api/metrics/route.js +73 -0
  40. package/src/app/api/monitoring/dashboard/route.js +18 -0
  41. package/src/cli.js +243 -0
  42. package/src/config/config-loader.js +112 -0
  43. package/src/config/constants.js +127 -0
  44. package/src/config/env.js +164 -0
  45. package/src/config/spec-helpers.js +232 -0
  46. package/src/engine.server.js +212 -0
  47. package/src/index.js +368 -0
  48. package/src/lib/accessibility.js +162 -0
  49. package/src/lib/action-factory.js +34 -0
  50. package/src/lib/action-utils.js +21 -0
  51. package/src/lib/alert-manager.js +189 -0
  52. package/src/lib/api-error-wrapper.js +125 -0
  53. package/src/lib/api-helpers.js +53 -0
  54. package/src/lib/api.js +82 -0
  55. package/src/lib/audit-logger-enhanced.js +117 -0
  56. package/src/lib/audit-logger.js +193 -0
  57. package/src/lib/auth-middleware.js +102 -0
  58. package/src/lib/auth-route-helpers.js +83 -0
  59. package/src/lib/business-rules-engine.js +86 -0
  60. package/src/lib/compression.js +44 -0
  61. package/src/lib/config-field-helpers.js +91 -0
  62. package/src/lib/config-generator-engine.js +445 -0
  63. package/src/lib/config-helpers.js +120 -0
  64. package/src/lib/connection-guard.js +79 -0
  65. package/src/lib/crud-action-helpers.js +34 -0
  66. package/src/lib/crud-factory.js +83 -0
  67. package/src/lib/crud-handlers.js +244 -0
  68. package/src/lib/csrf-protection.js +63 -0
  69. package/src/lib/database-core.js +258 -0
  70. package/src/lib/database-migrations.js +96 -0
  71. package/src/lib/date-utils.js +159 -0
  72. package/src/lib/db-backup.js +97 -0
  73. package/src/lib/db-monitor.js +127 -0
  74. package/src/lib/domain-loader.js +82 -0
  75. package/src/lib/email-sender.js +100 -0
  76. package/src/lib/error-boundary.js +134 -0
  77. package/src/lib/error-handler.js +84 -0
  78. package/src/lib/error-recovery.js +190 -0
  79. package/src/lib/error-resilience.js +130 -0
  80. package/src/lib/errors.js +69 -0
  81. package/src/lib/events-engine.js +182 -0
  82. package/src/lib/field-iterator.js +50 -0
  83. package/src/lib/field-registry.js +68 -0
  84. package/src/lib/field-types.js +154 -0
  85. package/src/lib/generic-crud-handler.js +32 -0
  86. package/src/lib/health-monitor.js +134 -0
  87. package/src/lib/hook-engine.js +169 -0
  88. package/src/lib/hot-reload/cache-invalidator.js +115 -0
  89. package/src/lib/hot-reload/checkpoint.js +95 -0
  90. package/src/lib/hot-reload/debug-exposure.js +67 -0
  91. package/src/lib/hot-reload/directory-watcher.js +96 -0
  92. package/src/lib/hot-reload/index.js +50 -0
  93. package/src/lib/hot-reload/mutex.js +75 -0
  94. package/src/lib/hot-reload/promise-container.js +66 -0
  95. package/src/lib/hot-reload/route-wrapper.js +46 -0
  96. package/src/lib/hot-reload/safe-error.js +51 -0
  97. package/src/lib/hot-reload/supervisor.js +161 -0
  98. package/src/lib/hot-reload/timeout-wrapper.js +52 -0
  99. package/src/lib/http-methods-factory.js +25 -0
  100. package/src/lib/index-optimizer.js +96 -0
  101. package/src/lib/index.js +35 -0
  102. package/src/lib/list-data-transform.js +39 -0
  103. package/src/lib/log-aggregator.js +116 -0
  104. package/src/lib/logger.js +55 -0
  105. package/src/lib/metrics-collector.js +102 -0
  106. package/src/lib/minifier.js +19 -0
  107. package/src/lib/monitoring-init.js +67 -0
  108. package/src/lib/next-compat.js +80 -0
  109. package/src/lib/next-polyfills.js +135 -0
  110. package/src/lib/perf-monitor.js +91 -0
  111. package/src/lib/progress-components.js +181 -0
  112. package/src/lib/query-cache.js +126 -0
  113. package/src/lib/query-engine-write.js +221 -0
  114. package/src/lib/query-engine.js +399 -0
  115. package/src/lib/query-perf.js +117 -0
  116. package/src/lib/query-string-adapter.js +75 -0
  117. package/src/lib/realtime-server.js +67 -0
  118. package/src/lib/render-cache.js +61 -0
  119. package/src/lib/request-tracker.js +43 -0
  120. package/src/lib/resource-hints.js +29 -0
  121. package/src/lib/resource-monitor.js +117 -0
  122. package/src/lib/response-formatter.js +80 -0
  123. package/src/lib/route-helpers.js +33 -0
  124. package/src/lib/route-resolver.js +142 -0
  125. package/src/lib/safe-json.js +8 -0
  126. package/src/lib/server-bootstrap.js +71 -0
  127. package/src/lib/stage-pipeline.js +153 -0
  128. package/src/lib/state-protocol.js +171 -0
  129. package/src/lib/state-transport-client.js +169 -0
  130. package/src/lib/state-transport-reconnect.js +121 -0
  131. package/src/lib/state-transport-server.js +181 -0
  132. package/src/lib/static-server.js +97 -0
  133. package/src/lib/status-helpers.js +98 -0
  134. package/src/lib/universal-handler.js +7 -0
  135. package/src/lib/utils.js +93 -0
  136. package/src/lib/validate.js +197 -0
  137. package/src/lib/validation/business-validators.js +61 -0
  138. package/src/lib/validation/csrf.js +51 -0
  139. package/src/lib/validation/file-validators.js +34 -0
  140. package/src/lib/validation/format-validators.js +106 -0
  141. package/src/lib/validation/index.js +19 -0
  142. package/src/lib/validation/rate-limit.js +31 -0
  143. package/src/lib/validation/security-validators.js +78 -0
  144. package/src/lib/validation-middleware.js +133 -0
  145. package/src/lib/validators.js +105 -0
  146. package/src/lib/with-audit-logging.js +63 -0
  147. package/src/lib/with-error-handler.js +31 -0
  148. package/src/lib/workflow-engine.js +250 -0
  149. package/src/server/server.js +305 -0
  150. package/src/services/collaborator-role.service.js +205 -0
  151. package/src/services/email-sender.js +105 -0
  152. package/src/services/notification-engine.js +110 -0
  153. package/src/services/permission.service.js +181 -0
  154. package/src/ui/advanced-search-renderer.js +42 -0
  155. package/src/ui/advanced-widgets.js +47 -0
  156. package/src/ui/auth-pages.js +114 -0
  157. package/src/ui/auth-styles.js +53 -0
  158. package/src/ui/client.js +99 -0
  159. package/src/ui/collaboration-dialogs.js +31 -0
  160. package/src/ui/common-handlers.js +156 -0
  161. package/src/ui/component-engine.js +103 -0
  162. package/src/ui/dashboard-renderer.js +150 -0
  163. package/src/ui/dialog-engine.js +147 -0
  164. package/src/ui/dialog-factory.js +38 -0
  165. package/src/ui/engagement-cards.js +76 -0
  166. package/src/ui/engagement-dialogs.js +100 -0
  167. package/src/ui/engagement-grid-renderer.js +109 -0
  168. package/src/ui/entity-renderer.js +176 -0
  169. package/src/ui/event-delegation.js +108 -0
  170. package/src/ui/fetch-json.js +24 -0
  171. package/src/ui/file-dialogs.js +81 -0
  172. package/src/ui/flexup-report-renderer.js +173 -0
  173. package/src/ui/format-helpers.js +102 -0
  174. package/src/ui/global-tags.js +144 -0
  175. package/src/ui/highlight-threading-renderer.js +176 -0
  176. package/src/ui/idle-logout.js +156 -0
  177. package/src/ui/job-management-renderer.js +61 -0
  178. package/src/ui/layout.js +219 -0
  179. package/src/ui/letter-dialogs.js +37 -0
  180. package/src/ui/ml-console-renderer.js +108 -0
  181. package/src/ui/monitoring-dashboard-client.js +136 -0
  182. package/src/ui/monitoring-dashboard.js +134 -0
  183. package/src/ui/notifications-renderer.js +51 -0
  184. package/src/ui/page-handler-admin.js +121 -0
  185. package/src/ui/page-handler-helpers.js +111 -0
  186. package/src/ui/page-handler-reviews.js +165 -0
  187. package/src/ui/page-handler-rfi.js +42 -0
  188. package/src/ui/page-handler.js +210 -0
  189. package/src/ui/password-reset-page.js +146 -0
  190. package/src/ui/perf-helpers.js +96 -0
  191. package/src/ui/perf-renderer.js +71 -0
  192. package/src/ui/permissions-ui.js +163 -0
  193. package/src/ui/picker-dialogs.js +100 -0
  194. package/src/ui/render-helpers.js +124 -0
  195. package/src/ui/renderer.js +35 -0
  196. package/src/ui/review-comparison-renderer.js +58 -0
  197. package/src/ui/review-detail-panels.js +71 -0
  198. package/src/ui/review-detail-renderer.js +170 -0
  199. package/src/ui/review-detail-script.js +95 -0
  200. package/src/ui/review-mwr-renderer.js +113 -0
  201. package/src/ui/review-renderer.js +202 -0
  202. package/src/ui/review-widgets.js +88 -0
  203. package/src/ui/review-zone-nav.js +12 -0
  204. package/src/ui/rfi-detail-renderer.js +191 -0
  205. package/src/ui/rfi-renderer.js +194 -0
  206. package/src/ui/rfi-report-renderer.js +56 -0
  207. package/src/ui/rippleui.css +1 -0
  208. package/src/ui/settings-renderer-advanced.js +195 -0
  209. package/src/ui/settings-renderer-advanced2.js +158 -0
  210. package/src/ui/settings-renderer-teams.js +112 -0
  211. package/src/ui/settings-renderer.js +166 -0
  212. package/src/ui/spacing-system.js +155 -0
  213. package/src/ui/standalone-login.js +109 -0
  214. package/src/ui/styles.css +2530 -0
  215. package/src/ui/styles2.css +1602 -0
  216. package/src/ui/test-page.js +23 -0
  217. package/src/ui/validation-rules.js +73 -0
  218. package/src/ui/validation-ui.js +147 -0
  219. package/src/ui/virtual-scroll.js +107 -0
  220. package/src/ui/webjsx.js +61 -0
  221. package/src/ui/widgets.js +152 -0
@@ -0,0 +1,97 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+ const ROOT = path.join(__dirname, '../..');
7
+
8
+ export function serveStatic(pathname, req, res, compress, getCacheHeaders, loadModule) {
9
+ const acceptEncoding = req.headers['accept-encoding'] || '';
10
+
11
+ if (pathname === '/favicon.ico') {
12
+ res.setHeader('Content-Type', 'image/x-icon');
13
+ res.setHeader('Cache-Control', 'public, max-age=86400');
14
+ res.setHeader('Content-Length', '0');
15
+ res.writeHead(200);
16
+ res.end();
17
+ return true;
18
+ }
19
+
20
+ if (pathname === '/manifest.json') {
21
+ const manifest = JSON.stringify({ name: 'MOONLANDING', short_name: 'Moonlanding', start_url: '/', display: 'standalone', background_color: '#f1f5f9', theme_color: '#04141f' });
22
+ res.setHeader('Content-Type', 'application/json');
23
+ res.setHeader('Cache-Control', 'public, max-age=86400');
24
+ res.setHeader('Content-Length', Buffer.byteLength(manifest, 'utf-8'));
25
+ res.writeHead(200);
26
+ res.end(manifest);
27
+ return true;
28
+ }
29
+
30
+ if (pathname === '/service-worker.js') {
31
+ const swPath = path.join(ROOT, 'src/service-worker.js');
32
+ if (fs.existsSync(swPath)) {
33
+ const content = fs.readFileSync(swPath, 'utf-8');
34
+ const cacheHeaders = getCacheHeaders('dynamic');
35
+ Object.entries(cacheHeaders).forEach(([k, v]) => res.setHeader(k, v));
36
+ res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
37
+ res.setHeader('Content-Length', Buffer.byteLength(content, 'utf-8'));
38
+ res.writeHead(200);
39
+ res.end(content);
40
+ return true;
41
+ }
42
+ }
43
+
44
+ if (pathname.startsWith('/lib/webjsx/')) {
45
+ const file = pathname.slice(12);
46
+ const filePath = path.join(ROOT, 'node_modules/webjsx/dist', file);
47
+ if (!fs.existsSync(filePath)) return false;
48
+ const content = fs.readFileSync(filePath, 'utf-8');
49
+ const cacheHeaders = getCacheHeaders('static', 31536000);
50
+ Object.entries(cacheHeaders).forEach(([k, v]) => res.setHeader(k, v));
51
+ const { content: finalContent, encoding } = compress(content, acceptEncoding);
52
+ res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
53
+ if (encoding) res.setHeader('Content-Encoding', encoding);
54
+ res.setHeader('Content-Length', Buffer.byteLength(finalContent));
55
+ res.writeHead(200);
56
+ res.end(finalContent);
57
+ return true;
58
+ }
59
+
60
+ if (pathname.startsWith('/ui/') && pathname.endsWith('.css')) {
61
+ const cssPath = path.join(ROOT, 'src/ui', path.basename(pathname));
62
+ if (!fs.existsSync(cssPath)) return false;
63
+ let content = fs.readFileSync(cssPath, 'utf-8');
64
+ const etag = `"${content.length}-${fs.statSync(cssPath).mtimeMs.toString(36)}"`;
65
+ if (req.headers['if-none-match'] === etag) { res.writeHead(304); res.end(); return true; }
66
+ res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate');
67
+ res.setHeader('ETag', etag);
68
+ const { content: finalContent, encoding } = compress(content, acceptEncoding);
69
+ res.setHeader('Content-Type', 'text/css; charset=utf-8');
70
+ if (encoding) res.setHeader('Content-Encoding', encoding);
71
+ res.setHeader('Content-Length', Buffer.byteLength(finalContent));
72
+ res.writeHead(200);
73
+ res.end(finalContent);
74
+ return true;
75
+ }
76
+
77
+ if (pathname === '/ui/client.js' || pathname === '/ui/event-delegation.js' || pathname === '/ui/common-handlers.js') {
78
+ const jsPath = path.join(ROOT, 'src/ui', pathname.split('/').pop());
79
+ if (!fs.existsSync(jsPath)) return false;
80
+ const content = fs.readFileSync(jsPath, 'utf-8');
81
+ const cacheHeaders = getCacheHeaders('static', 86400);
82
+ Object.entries(cacheHeaders).forEach(([k, v]) => res.setHeader(k, v));
83
+ const { content: finalContent, encoding } = compress(content, acceptEncoding);
84
+ res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
85
+ if (encoding) res.setHeader('Content-Encoding', encoding);
86
+ res.setHeader('Content-Length', Buffer.byteLength(finalContent));
87
+ res.writeHead(200);
88
+ res.end(finalContent);
89
+ return true;
90
+ }
91
+
92
+ return false;
93
+ }
94
+
95
+ export function html404() {
96
+ return `<!DOCTYPE html><html lang="en" data-theme="light"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>404 - Page Not Found | MOONLANDING</title><link href="/ui/rippleui.css" rel="stylesheet"><link href="/ui/styles2.css" rel="stylesheet"><style>body{margin:0;background:var(--color-bg,#f1f5f9);font-family:system-ui,sans-serif}.nav-shell{background:#04141f;padding:0 2rem;height:56px;display:flex;align-items:center}a.logo-link{color:#fff;text-decoration:none;font-weight:700;font-size:1.1rem}.error-shell{min-height:calc(100vh - 56px);display:flex;align-items:center;justify-content:center}.error-card{background:#fff;border-radius:12px;padding:3rem 4rem;text-align:center;box-shadow:0 1px 3px rgba(0,0,0,.1)}.error-code{font-size:4rem;font-weight:900;color:#04141f;line-height:1}.error-msg{font-size:1.2rem;color:#64748b;margin:0.5rem 0 2rem}.home-btn{display:inline-block;padding:0.75rem 2rem;background:#04141f;color:#fff;border-radius:8px;text-decoration:none;font-weight:600;font-size:0.95rem}</style></head><body><nav class="nav-shell"><a href="/" class="logo-link">MOONLANDING</a></nav><div class="error-shell"><div class="error-card"><div class="error-code">404</div><p class="error-msg">Page not found</p><a href="/" class="home-btn">Go to Dashboard</a></div></div></body></html>`;
97
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Status Helpers - Status enumerations and transition maps
3
+ * These are the standard status values used by moonlanding parity entities
4
+ */
5
+
6
+ // Engagement statuses
7
+ export const ENGAGEMENT_STATUS = {
8
+ DRAFT: 'draft',
9
+ SENT: 'sent',
10
+ ACCEPTED: 'accepted',
11
+ IN_PROGRESS: 'in_progress',
12
+ COMPLETED: 'completed',
13
+ CLOSED: 'closed',
14
+ CANCELLED: 'cancelled',
15
+ };
16
+
17
+ export const ENGAGEMENT_STAGE = {
18
+ DRAFT: 'draft',
19
+ SCOPE: 'scope',
20
+ KICKOFF: 'kickoff',
21
+ RFI: 'rfi',
22
+ REVIEW: 'review',
23
+ CLOSEOUT: 'closeout',
24
+ CLOSED: 'closed',
25
+ };
26
+
27
+ // RFI statuses
28
+ export const RFI_STATUS = {
29
+ DRAFT: 'draft',
30
+ OPEN: 'open',
31
+ DEFERRED: 'deferred',
32
+ ANSWERED: 'answered',
33
+ CLARIFICATION: 'clarification',
34
+ CLOSED: 'closed',
35
+ };
36
+
37
+ export const RFI_CLIENT_STATUS = {
38
+ PENDING: 'pending',
39
+ ESCALATED: 'escalated',
40
+ RESOLVED: 'resolved',
41
+ };
42
+
43
+ export const RFI_AUDITOR_STATUS = {
44
+ REVIEW: 'review',
45
+ APPROVED: 'approved',
46
+ REJECTED: 'rejected',
47
+ };
48
+
49
+ // Review statuses
50
+ export const REVIEW_STATUS = {
51
+ DRAFT: 'draft',
52
+ ACTIVE: 'active',
53
+ ARCHIVED: 'archived',
54
+ COMPLETED: 'completed',
55
+ };
56
+
57
+ // Highlight statuses
58
+ export const HIGHLIGHT_STATUS = {
59
+ OPEN: 'open',
60
+ RESOLVED: 'resolved',
61
+ REJECTED: 'rejected',
62
+ };
63
+
64
+ // Standard stage transitions
65
+ export const STAGE_TRANSITIONS = {
66
+ draft: 'scope',
67
+ scope: 'kickoff',
68
+ kickoff: 'rfi',
69
+ rfi: 'review',
70
+ review: 'closeout',
71
+ closeout: 'closed',
72
+ };
73
+
74
+ /**
75
+ * Get next stage in standard lifecycle
76
+ * @param {string} currentStage
77
+ * @returns {string|null}
78
+ */
79
+ export function getNextStage(currentStage) {
80
+ return STAGE_TRANSITIONS[currentStage] || null;
81
+ }
82
+
83
+ /**
84
+ * Get all valid transitions for an engagement
85
+ * @param {string} currentStage
86
+ * @returns {Array<string>}
87
+ */
88
+ export function getValidTransitions(currentStage) {
89
+ const transitions = [];
90
+ for (const [from, to] of Object.entries(STAGE_TRANSITIONS)) {
91
+ if (from === currentStage) transitions.push(to);
92
+ }
93
+ // Also allow backward steps
94
+ for (const [from, to] of Object.entries(STAGE_TRANSITIONS)) {
95
+ if (to === currentStage) transitions.push(from);
96
+ }
97
+ return [...new Set(transitions)];
98
+ }
@@ -0,0 +1,7 @@
1
+ import { createCrudHandlers } from '@/lib/crud-factory';
2
+
3
+ export function createUniversalHandler(entityName) {
4
+ return createCrudHandlers(entityName);
5
+ }
6
+
7
+ export { createUniversalHandler as createApiHandler };
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Utility Functions - Common helpers
3
+ */
4
+
5
+ /**
6
+ * Get display name for user
7
+ * @param {object} user - User with name or email
8
+ * @returns {string}
9
+ */
10
+ export function getDisplayName(user) {
11
+ if (!user) return 'Unknown';
12
+ return user.name || user.email || user.id || 'Unknown';
13
+ }
14
+
15
+ /**
16
+ * Get initials from name or email
17
+ * @param {string|object} userOrName
18
+ * @returns {string}
19
+ */
20
+ export function getInitials(userOrName) {
21
+ let name = typeof userOrName === 'string' ? userOrName : getDisplayName(userOrName);
22
+ const parts = name.trim().split(/\s+/);
23
+ if (parts.length === 1) {
24
+ return parts[0].charAt(0).toUpperCase();
25
+ }
26
+ return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
27
+ }
28
+
29
+ /**
30
+ * Get user role
31
+ * @param {object} user
32
+ * @returns {string}
33
+ */
34
+ export function getUserRole(user) {
35
+ return user?.role || 'user';
36
+ }
37
+
38
+ /**
39
+ * Slugify string (URL-safe)
40
+ * @param {string} str
41
+ * @returns {string}
42
+ */
43
+ export function slugify(str) {
44
+ return str
45
+ .toLowerCase()
46
+ .trim()
47
+ .replace(/[^\w\s-]/g, '')
48
+ .replace(/[\s_-]+/g, '-')
49
+ .replace(/^-+|-+$/g, '');
50
+ }
51
+
52
+ /**
53
+ * Truncate text
54
+ * @param {string} text
55
+ * @param {number} maxLength
56
+ * @param {string} suffix
57
+ * @returns {string}
58
+ */
59
+ export function truncate(text, maxLength = 50, suffix = '...') {
60
+ if (!text) return '';
61
+ if (text.length <= maxLength) return text;
62
+ return text.slice(0, maxLength - suffix.length) + suffix;
63
+ }
64
+
65
+ /**
66
+ * Deep merge objects
67
+ * @param {object} target
68
+ * @param {object} source
69
+ * @returns {object}
70
+ */
71
+ export function deepMerge(target, source) {
72
+ const output = { ...target };
73
+ for (const key of Object.keys(source)) {
74
+ if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
75
+ output[key] = deepMerge(target[key] || {}, source[key]);
76
+ } else {
77
+ output[key] = source[key];
78
+ }
79
+ }
80
+ return output;
81
+ }
82
+
83
+ /**
84
+ * Generate a random color
85
+ * @returns {string} Hex color
86
+ */
87
+ export function randomColor() {
88
+ const colors = [
89
+ '#228be6', '#40c057', '#fab005', '#fa5252', '#15aabf',
90
+ '#7950f2', '#f03e3e', '#2f9e44', '#e67700', '#cc5de8',
91
+ ];
92
+ return colors[Math.floor(Math.random() * colors.length)];
93
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Validation System - Field and entity validation
3
+ * Adapted from moonlanding/src/lib/validate.js
4
+ */
5
+
6
+ import { getSpec } from '../config/spec-helpers.js';
7
+ import { isValidEmail as checkEmailFormat } from './validators.js';
8
+
9
+ const HTML_ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
10
+
11
+ /**
12
+ * Sanitize HTML
13
+ * @param {string} str
14
+ * @returns {string}
15
+ */
16
+ function sanitizeHtml(str) {
17
+ return typeof str === 'string' ? str.replace(/[&<>"']/g, c => HTML_ESC[c]) : str;
18
+ }
19
+
20
+ /**
21
+ * Validate a single field
22
+ * @param {object} fieldDef
23
+ * @param {any} value
24
+ * @param {object} options
25
+ * @returns {Promise<{valid: boolean, error?: string}>}
26
+ */
27
+ export async function validateField(fieldDef, value, options = {}) {
28
+ const { fieldName, entityName, existingValue } = options;
29
+
30
+ if (fieldDef.auto || fieldDef.auto_generate || fieldDef.readOnly) {
31
+ return { valid: true };
32
+ }
33
+
34
+ if (fieldDef.required && (value === null || value === undefined || value === '')) {
35
+ return { valid: false, error: `Field '${fieldName}' is required` };
36
+ }
37
+
38
+ if (value === null || value === undefined || value === '') {
39
+ return { valid: true }; // Optional field with no value
40
+ }
41
+
42
+ // Type validation
43
+ const typeErr = validateType(fieldDef, value, fieldName);
44
+ if (typeErr) return { valid: false, error: typeErr };
45
+
46
+ // Enum validation
47
+ if (fieldDef.type === 'enum' && fieldDef.options) {
48
+ const allowed = resolveEnumOptions(fieldDef, entityName);
49
+ if (allowed.length > 0 && !allowed.includes(value)) {
50
+ return {
51
+ valid: false,
52
+ error: `Invalid value for '${fieldName}'. Expected one of: ${allowed.join(', ')}`,
53
+ };
54
+ }
55
+ }
56
+
57
+ // Reference validation
58
+ if (fieldDef.type === 'ref' && fieldDef.ref) {
59
+ if (existingValue !== undefined && value === existingValue) {
60
+ return { valid: true };
61
+ }
62
+ try {
63
+ const { get } = await import('./query-engine.js');
64
+ const refTable = fieldDef.ref === 'user' ? 'users' : fieldDef.ref;
65
+ if (!get(refTable, value)) {
66
+ return {
67
+ valid: false,
68
+ error: `${fieldDef.ref.charAt(0).toUpperCase() + fieldDef.ref.slice(1)} with id '${value}' not found`,
69
+ };
70
+ }
71
+ } catch {
72
+ // Reference table might not exist yet
73
+ }
74
+ }
75
+
76
+ return { valid: true };
77
+ }
78
+
79
+ /**
80
+ * Validate type of value
81
+ * @param {object} fieldDef
82
+ * @param {any} value
83
+ * @param {string} fieldName
84
+ * @returns {string|null}
85
+ */
86
+ function validateType(fieldDef, value, fieldName) {
87
+ const { type, min, max } = fieldDef;
88
+
89
+ if (type === 'string' || type === 'text') {
90
+ if (typeof value !== 'string') return `Field '${fieldName}' must be a string`;
91
+ } else if (type === 'number' || type === 'int' || type === 'decimal') {
92
+ if (typeof value !== 'number' || isNaN(value)) return `Field '${fieldName}' must be a number`;
93
+ if (min !== undefined && value < min) return `Field '${fieldName}' must be at least ${min}`;
94
+ if (max !== undefined && value > max) return `Field '${fieldName}' must be at most ${max}`;
95
+ } else if (type === 'boolean' || type === 'bool') {
96
+ if (typeof value !== 'boolean') return `Field '${fieldName}' must be a boolean`;
97
+ } else if (type === 'timestamp' || type === 'date') {
98
+ if (isNaN(Number(value))) return `Field '${fieldName}' must be a valid timestamp`;
99
+ } else if (type === 'json') {
100
+ if (typeof value === 'string') {
101
+ try { JSON.parse(value); } catch { return `Field '${fieldName}' must be valid JSON`; }
102
+ } else if (typeof value !== 'object') {
103
+ return `Field '${fieldName}' must be an object or JSON string`;
104
+ }
105
+ }
106
+
107
+ return null;
108
+ }
109
+
110
+ /**
111
+ * Resolve enum options from field definition
112
+ * @param {object} fieldDef
113
+ * @param {string} entityName
114
+ * @returns {Array<string>}
115
+ */
116
+ function resolveEnumOptions(fieldDef, entityName) {
117
+ if (Array.isArray(fieldDef.options)) {
118
+ return fieldDef.options.map(o => typeof o === 'object' ? o.value : o);
119
+ }
120
+
121
+ if (typeof fieldDef.options === 'string') {
122
+ try {
123
+ const spec = getSpec(entityName);
124
+ const list = spec.options?.[fieldDef.options];
125
+ if (list) return list.map(o => typeof o === 'object' ? o.value : o);
126
+ } catch {
127
+ // Skip
128
+ }
129
+ }
130
+
131
+ return [];
132
+ }
133
+
134
+ /**
135
+ * Validate all fields for an entity
136
+ * @param {string} entityName
137
+ * @param {object} data
138
+ * @param {object} existingRecord
139
+ * @returns {Promise<object>} Errors object keyed by field name
140
+ */
141
+ export async function validateEntity(entityName, data, existingRecord = null) {
142
+ const spec = getSpec(entityName);
143
+ const errors = {};
144
+
145
+ for (const [fieldName, fieldDef] of Object.entries(spec.fields || {})) {
146
+ const value = data[fieldName];
147
+ const result = await validateField(fieldDef, value, {
148
+ fieldName,
149
+ entityName,
150
+ existingValue: existingRecord?.[fieldName],
151
+ });
152
+
153
+ if (!result.valid && result.error) {
154
+ errors[fieldName] = result.error;
155
+ }
156
+ }
157
+
158
+ return errors;
159
+ }
160
+
161
+ /**
162
+ * Validate update (only changed fields)
163
+ * @param {string} entityName
164
+ * @param {object} changes
165
+ * @param {object} existingRecord
166
+ * @returns {Promise<object>}
167
+ */
168
+ export async function validateUpdate(entityName, changes, existingRecord) {
169
+ const spec = getSpec(entityName);
170
+ const errors = {};
171
+
172
+ for (const [fieldName, fieldDef] of Object.entries(spec.fields || {})) {
173
+ if (!(fieldName in changes)) continue;
174
+
175
+ const value = changes[fieldName];
176
+ const result = await validateField(fieldDef, value, {
177
+ fieldName,
178
+ entityName,
179
+ existingValue: existingRecord?.[fieldName],
180
+ });
181
+
182
+ if (!result.valid && result.error) {
183
+ errors[fieldName] = result.error;
184
+ }
185
+ }
186
+
187
+ return errors;
188
+ }
189
+
190
+ /**
191
+ * Check if errors object has any errors
192
+ * @param {object} errors
193
+ * @returns {boolean}
194
+ */
195
+ export function hasErrors(errors) {
196
+ return errors && Object.keys(errors).length > 0;
197
+ }
@@ -0,0 +1,61 @@
1
+ export function validateBusinessRules(entityName, data, existingRecord) {
2
+ const errors = {};
3
+
4
+ if (entityName === 'engagement') {
5
+ if (data.start_date && data.end_date) {
6
+ const start = new Date(data.start_date * 1000);
7
+ const end = new Date(data.end_date * 1000);
8
+ if (end < start) {
9
+ errors.end_date = 'End date must be after start date';
10
+ }
11
+ }
12
+
13
+ if (data.budget && data.budget < 0) {
14
+ errors.budget = 'Budget cannot be negative';
15
+ }
16
+
17
+ if (data.status && existingRecord?.status) {
18
+ const validTransitions = {
19
+ draft: ['active', 'cancelled'],
20
+ active: ['completed', 'on_hold', 'cancelled'],
21
+ on_hold: ['active', 'cancelled'],
22
+ completed: [],
23
+ cancelled: []
24
+ };
25
+ const allowed = validTransitions[existingRecord.status] || [];
26
+ if (!allowed.includes(data.status) && data.status !== existingRecord.status) {
27
+ errors.status = `Cannot transition from ${existingRecord.status} to ${data.status}`;
28
+ }
29
+ }
30
+ }
31
+
32
+ if (entityName === 'rfi') {
33
+ if (data.deadline) {
34
+ const deadline = new Date(data.deadline * 1000);
35
+ const now = new Date();
36
+ const twoYearsFromNow = new Date();
37
+ twoYearsFromNow.setFullYear(twoYearsFromNow.getFullYear() + 2);
38
+
39
+ if (deadline < now) {
40
+ errors.deadline = 'Deadline cannot be in the past';
41
+ }
42
+ if (deadline > twoYearsFromNow) {
43
+ errors.deadline = 'Deadline cannot be more than 2 years in future';
44
+ }
45
+ }
46
+ }
47
+
48
+ if (entityName === 'user') {
49
+ if (data.role && !['admin', 'manager', 'reviewer', 'client_user'].includes(data.role)) {
50
+ errors.role = 'Invalid user role';
51
+ }
52
+ }
53
+
54
+ if (entityName === 'review') {
55
+ if (data.score && (data.score < 0 || data.score > 100)) {
56
+ errors.score = 'Score must be between 0 and 100';
57
+ }
58
+ }
59
+
60
+ return errors;
61
+ }
@@ -0,0 +1,51 @@
1
+ import crypto from 'crypto';
2
+
3
+ const CSRF_TOKEN_LENGTH = 32;
4
+ const CSRF_TOKEN_EXPIRY = 24 * 60 * 60 * 1000;
5
+
6
+ if (!global.csrfTokenStore) {
7
+ global.csrfTokenStore = new Map();
8
+ }
9
+
10
+ export function generateCSRFToken(sessionId) {
11
+ const token = crypto.randomBytes(CSRF_TOKEN_LENGTH).toString('hex');
12
+ const expiresAt = Date.now() + CSRF_TOKEN_EXPIRY;
13
+
14
+ global.csrfTokenStore.set(token, { sessionId, expiresAt });
15
+
16
+ return token;
17
+ }
18
+
19
+ export function validateCSRFToken(token, sessionId) {
20
+ if (!token || typeof token !== 'string') {
21
+ return { valid: false, reason: 'CSRF token required' };
22
+ }
23
+
24
+ const record = global.csrfTokenStore.get(token);
25
+
26
+ if (!record) {
27
+ return { valid: false, reason: 'Invalid CSRF token' };
28
+ }
29
+
30
+ if (Date.now() > record.expiresAt) {
31
+ global.csrfTokenStore.delete(token);
32
+ return { valid: false, reason: 'CSRF token expired' };
33
+ }
34
+
35
+ if (record.sessionId !== sessionId) {
36
+ return { valid: false, reason: 'CSRF token session mismatch' };
37
+ }
38
+
39
+ return { valid: true };
40
+ }
41
+
42
+ export function cleanupExpiredCSRFTokens() {
43
+ const now = Date.now();
44
+ for (const [token, record] of global.csrfTokenStore.entries()) {
45
+ if (now > record.expiresAt) {
46
+ global.csrfTokenStore.delete(token);
47
+ }
48
+ }
49
+ }
50
+
51
+ setInterval(cleanupExpiredCSRFTokens, 60 * 60 * 1000);
@@ -0,0 +1,34 @@
1
+ import { detectPathTraversal } from '@/lib/validation/security-validators';
2
+
3
+ export function validateFileUpload(file, options = {}) {
4
+ const { maxSize = 10 * 1024 * 1024, allowedTypes = [], allowedExtensions = [] } = options;
5
+ const errors = [];
6
+
7
+ if (file.size > maxSize) {
8
+ errors.push(`File size ${file.size} exceeds maximum ${maxSize} bytes`);
9
+ }
10
+
11
+ if (allowedTypes.length > 0 && !allowedTypes.includes(file.type)) {
12
+ errors.push(`File type ${file.type} not allowed. Allowed: ${allowedTypes.join(', ')}`);
13
+ }
14
+
15
+ if (allowedExtensions.length > 0) {
16
+ const ext = file.name.split('.').pop()?.toLowerCase();
17
+ if (!ext || !allowedExtensions.includes(ext)) {
18
+ errors.push(`File extension not allowed. Allowed: ${allowedExtensions.join(', ')}`);
19
+ }
20
+ }
21
+
22
+ const pathCheck = detectPathTraversal(file.name);
23
+ if (!pathCheck.safe) {
24
+ errors.push(pathCheck.reason);
25
+ }
26
+
27
+ const DANGEROUS_EXTENSIONS = ['exe', 'bat', 'cmd', 'sh', 'ps1', 'scr', 'com', 'pif', 'msi'];
28
+ const ext = file.name.split('.').pop()?.toLowerCase();
29
+ if (ext && DANGEROUS_EXTENSIONS.includes(ext)) {
30
+ errors.push(`Dangerous file extension: ${ext}`);
31
+ }
32
+
33
+ return { valid: errors.length === 0, errors };
34
+ }