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.
- package/LICENSE +21 -0
- package/README.md +398 -0
- package/package.json +73 -0
- package/src/adapters/google-auth.js +148 -0
- package/src/adapters/google-drive.js +209 -0
- package/src/app/api/[entity]/[[...path]]/route.js +33 -0
- package/src/app/api/audit/dashboard/route.js +77 -0
- package/src/app/api/audit/logs/route.js +46 -0
- package/src/app/api/audit/permissions/[id]/route.js +37 -0
- package/src/app/api/audit/permissions/route.js +93 -0
- package/src/app/api/audit/permissions/stats/route.js +29 -0
- package/src/app/api/audit/route.js +79 -0
- package/src/app/api/audit/stats/route.js +18 -0
- package/src/app/api/auth/google/callback/route.js +94 -0
- package/src/app/api/auth/google/route.js +58 -0
- package/src/app/api/auth/login/route.js +121 -0
- package/src/app/api/auth/logout/route.js +52 -0
- package/src/app/api/auth/me/route.js +16 -0
- package/src/app/api/auth/mwr-bridge/route.js +77 -0
- package/src/app/api/auth/password-reset/route.js +65 -0
- package/src/app/api/cron/trigger/route.js +58 -0
- package/src/app/api/csrf-token/route.js +8 -0
- package/src/app/api/debug/config/route.js +22 -0
- package/src/app/api/debug/hooks/route.js +16 -0
- package/src/app/api/debug/plugins/route.js +20 -0
- package/src/app/api/debug/sqlite/route.js +21 -0
- package/src/app/api/debug/sync/route.js +29 -0
- package/src/app/api/debug/workflow/route.js +20 -0
- package/src/app/api/domains/[domain]/route.js +26 -0
- package/src/app/api/domains/route.js +21 -0
- package/src/app/api/email/allocate/batch/route.js +106 -0
- package/src/app/api/email/allocate/route.js +141 -0
- package/src/app/api/email/receive/route.js +158 -0
- package/src/app/api/email/route.js +3 -0
- package/src/app/api/email/send/route.js +77 -0
- package/src/app/api/email/unallocated/route.js +47 -0
- package/src/app/api/files/[id]/route.js +38 -0
- package/src/app/api/health/route.js +95 -0
- package/src/app/api/metrics/route.js +73 -0
- package/src/app/api/monitoring/dashboard/route.js +18 -0
- package/src/cli.js +243 -0
- package/src/config/config-loader.js +112 -0
- package/src/config/constants.js +127 -0
- package/src/config/env.js +164 -0
- package/src/config/spec-helpers.js +232 -0
- package/src/engine.server.js +212 -0
- package/src/index.js +368 -0
- package/src/lib/accessibility.js +162 -0
- package/src/lib/action-factory.js +34 -0
- package/src/lib/action-utils.js +21 -0
- package/src/lib/alert-manager.js +189 -0
- package/src/lib/api-error-wrapper.js +125 -0
- package/src/lib/api-helpers.js +53 -0
- package/src/lib/api.js +82 -0
- package/src/lib/audit-logger-enhanced.js +117 -0
- package/src/lib/audit-logger.js +193 -0
- package/src/lib/auth-middleware.js +102 -0
- package/src/lib/auth-route-helpers.js +83 -0
- package/src/lib/business-rules-engine.js +86 -0
- package/src/lib/compression.js +44 -0
- package/src/lib/config-field-helpers.js +91 -0
- package/src/lib/config-generator-engine.js +445 -0
- package/src/lib/config-helpers.js +120 -0
- package/src/lib/connection-guard.js +79 -0
- package/src/lib/crud-action-helpers.js +34 -0
- package/src/lib/crud-factory.js +83 -0
- package/src/lib/crud-handlers.js +244 -0
- package/src/lib/csrf-protection.js +63 -0
- package/src/lib/database-core.js +258 -0
- package/src/lib/database-migrations.js +96 -0
- package/src/lib/date-utils.js +159 -0
- package/src/lib/db-backup.js +97 -0
- package/src/lib/db-monitor.js +127 -0
- package/src/lib/domain-loader.js +82 -0
- package/src/lib/email-sender.js +100 -0
- package/src/lib/error-boundary.js +134 -0
- package/src/lib/error-handler.js +84 -0
- package/src/lib/error-recovery.js +190 -0
- package/src/lib/error-resilience.js +130 -0
- package/src/lib/errors.js +69 -0
- package/src/lib/events-engine.js +182 -0
- package/src/lib/field-iterator.js +50 -0
- package/src/lib/field-registry.js +68 -0
- package/src/lib/field-types.js +154 -0
- package/src/lib/generic-crud-handler.js +32 -0
- package/src/lib/health-monitor.js +134 -0
- package/src/lib/hook-engine.js +169 -0
- package/src/lib/hot-reload/cache-invalidator.js +115 -0
- package/src/lib/hot-reload/checkpoint.js +95 -0
- package/src/lib/hot-reload/debug-exposure.js +67 -0
- package/src/lib/hot-reload/directory-watcher.js +96 -0
- package/src/lib/hot-reload/index.js +50 -0
- package/src/lib/hot-reload/mutex.js +75 -0
- package/src/lib/hot-reload/promise-container.js +66 -0
- package/src/lib/hot-reload/route-wrapper.js +46 -0
- package/src/lib/hot-reload/safe-error.js +51 -0
- package/src/lib/hot-reload/supervisor.js +161 -0
- package/src/lib/hot-reload/timeout-wrapper.js +52 -0
- package/src/lib/http-methods-factory.js +25 -0
- package/src/lib/index-optimizer.js +96 -0
- package/src/lib/index.js +35 -0
- package/src/lib/list-data-transform.js +39 -0
- package/src/lib/log-aggregator.js +116 -0
- package/src/lib/logger.js +55 -0
- package/src/lib/metrics-collector.js +102 -0
- package/src/lib/minifier.js +19 -0
- package/src/lib/monitoring-init.js +67 -0
- package/src/lib/next-compat.js +80 -0
- package/src/lib/next-polyfills.js +135 -0
- package/src/lib/perf-monitor.js +91 -0
- package/src/lib/progress-components.js +181 -0
- package/src/lib/query-cache.js +126 -0
- package/src/lib/query-engine-write.js +221 -0
- package/src/lib/query-engine.js +399 -0
- package/src/lib/query-perf.js +117 -0
- package/src/lib/query-string-adapter.js +75 -0
- package/src/lib/realtime-server.js +67 -0
- package/src/lib/render-cache.js +61 -0
- package/src/lib/request-tracker.js +43 -0
- package/src/lib/resource-hints.js +29 -0
- package/src/lib/resource-monitor.js +117 -0
- package/src/lib/response-formatter.js +80 -0
- package/src/lib/route-helpers.js +33 -0
- package/src/lib/route-resolver.js +142 -0
- package/src/lib/safe-json.js +8 -0
- package/src/lib/server-bootstrap.js +71 -0
- package/src/lib/stage-pipeline.js +153 -0
- package/src/lib/state-protocol.js +171 -0
- package/src/lib/state-transport-client.js +169 -0
- package/src/lib/state-transport-reconnect.js +121 -0
- package/src/lib/state-transport-server.js +181 -0
- package/src/lib/static-server.js +97 -0
- package/src/lib/status-helpers.js +98 -0
- package/src/lib/universal-handler.js +7 -0
- package/src/lib/utils.js +93 -0
- package/src/lib/validate.js +197 -0
- package/src/lib/validation/business-validators.js +61 -0
- package/src/lib/validation/csrf.js +51 -0
- package/src/lib/validation/file-validators.js +34 -0
- package/src/lib/validation/format-validators.js +106 -0
- package/src/lib/validation/index.js +19 -0
- package/src/lib/validation/rate-limit.js +31 -0
- package/src/lib/validation/security-validators.js +78 -0
- package/src/lib/validation-middleware.js +133 -0
- package/src/lib/validators.js +105 -0
- package/src/lib/with-audit-logging.js +63 -0
- package/src/lib/with-error-handler.js +31 -0
- package/src/lib/workflow-engine.js +250 -0
- package/src/server/server.js +305 -0
- package/src/services/collaborator-role.service.js +205 -0
- package/src/services/email-sender.js +105 -0
- package/src/services/notification-engine.js +110 -0
- package/src/services/permission.service.js +181 -0
- package/src/ui/advanced-search-renderer.js +42 -0
- package/src/ui/advanced-widgets.js +47 -0
- package/src/ui/auth-pages.js +114 -0
- package/src/ui/auth-styles.js +53 -0
- package/src/ui/client.js +99 -0
- package/src/ui/collaboration-dialogs.js +31 -0
- package/src/ui/common-handlers.js +156 -0
- package/src/ui/component-engine.js +103 -0
- package/src/ui/dashboard-renderer.js +150 -0
- package/src/ui/dialog-engine.js +147 -0
- package/src/ui/dialog-factory.js +38 -0
- package/src/ui/engagement-cards.js +76 -0
- package/src/ui/engagement-dialogs.js +100 -0
- package/src/ui/engagement-grid-renderer.js +109 -0
- package/src/ui/entity-renderer.js +176 -0
- package/src/ui/event-delegation.js +108 -0
- package/src/ui/fetch-json.js +24 -0
- package/src/ui/file-dialogs.js +81 -0
- package/src/ui/flexup-report-renderer.js +173 -0
- package/src/ui/format-helpers.js +102 -0
- package/src/ui/global-tags.js +144 -0
- package/src/ui/highlight-threading-renderer.js +176 -0
- package/src/ui/idle-logout.js +156 -0
- package/src/ui/job-management-renderer.js +61 -0
- package/src/ui/layout.js +219 -0
- package/src/ui/letter-dialogs.js +37 -0
- package/src/ui/ml-console-renderer.js +108 -0
- package/src/ui/monitoring-dashboard-client.js +136 -0
- package/src/ui/monitoring-dashboard.js +134 -0
- package/src/ui/notifications-renderer.js +51 -0
- package/src/ui/page-handler-admin.js +121 -0
- package/src/ui/page-handler-helpers.js +111 -0
- package/src/ui/page-handler-reviews.js +165 -0
- package/src/ui/page-handler-rfi.js +42 -0
- package/src/ui/page-handler.js +210 -0
- package/src/ui/password-reset-page.js +146 -0
- package/src/ui/perf-helpers.js +96 -0
- package/src/ui/perf-renderer.js +71 -0
- package/src/ui/permissions-ui.js +163 -0
- package/src/ui/picker-dialogs.js +100 -0
- package/src/ui/render-helpers.js +124 -0
- package/src/ui/renderer.js +35 -0
- package/src/ui/review-comparison-renderer.js +58 -0
- package/src/ui/review-detail-panels.js +71 -0
- package/src/ui/review-detail-renderer.js +170 -0
- package/src/ui/review-detail-script.js +95 -0
- package/src/ui/review-mwr-renderer.js +113 -0
- package/src/ui/review-renderer.js +202 -0
- package/src/ui/review-widgets.js +88 -0
- package/src/ui/review-zone-nav.js +12 -0
- package/src/ui/rfi-detail-renderer.js +191 -0
- package/src/ui/rfi-renderer.js +194 -0
- package/src/ui/rfi-report-renderer.js +56 -0
- package/src/ui/rippleui.css +1 -0
- package/src/ui/settings-renderer-advanced.js +195 -0
- package/src/ui/settings-renderer-advanced2.js +158 -0
- package/src/ui/settings-renderer-teams.js +112 -0
- package/src/ui/settings-renderer.js +166 -0
- package/src/ui/spacing-system.js +155 -0
- package/src/ui/standalone-login.js +109 -0
- package/src/ui/styles.css +2530 -0
- package/src/ui/styles2.css +1602 -0
- package/src/ui/test-page.js +23 -0
- package/src/ui/validation-rules.js +73 -0
- package/src/ui/validation-ui.js +147 -0
- package/src/ui/virtual-scroll.js +107 -0
- package/src/ui/webjsx.js +61 -0
- 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
|
+
}
|
package/src/lib/utils.js
ADDED
|
@@ -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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
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
|
+
}
|