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,445 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config Generator Engine - Resolves entity specs from YAML config
|
|
3
|
+
* Core of Thatcher's configuration-driven architecture
|
|
4
|
+
* Adapted from moonlanding/src/lib/config-generator-engine.js
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import yaml from 'js-yaml';
|
|
8
|
+
import { LRUCache, deepFreeze, deepClone, recursiveResolve } from './config-helpers.js';
|
|
9
|
+
import { generateFieldsFromOverrides, ensureFieldLabels } from './config-field-helpers.js';
|
|
10
|
+
|
|
11
|
+
export class ConfigGeneratorEngine {
|
|
12
|
+
constructor(masterConfig) {
|
|
13
|
+
if (!masterConfig) throw new Error('[ConfigGeneratorEngine] masterConfig is required');
|
|
14
|
+
this.masterConfig = deepFreeze(masterConfig);
|
|
15
|
+
this.specCache = new LRUCache(100);
|
|
16
|
+
this._plugins = new Map();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Register a plugin to extend an entity
|
|
21
|
+
*/
|
|
22
|
+
registerPlugin(entityName, plugin = {}) {
|
|
23
|
+
if (!entityName || typeof entityName !== 'string') {
|
|
24
|
+
throw new Error('[ConfigGeneratorEngine] registerPlugin: entityName required');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const existing = this._plugins.get(entityName) || { fields: {}, hooks: [], validators: [] };
|
|
28
|
+
|
|
29
|
+
this._plugins.set(entityName, {
|
|
30
|
+
fields: { ...existing.fields, ...(plugin.fields || {}) },
|
|
31
|
+
hooks: [...existing.hooks, ...(plugin.hooks || [])],
|
|
32
|
+
validators: [...existing.validators, ...(plugin.validators || [])],
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
this.specCache.clear();
|
|
36
|
+
|
|
37
|
+
// Register hooks with global hook engine if available
|
|
38
|
+
for (const { event, handler } of (plugin.hooks || [])) {
|
|
39
|
+
if (event && handler && globalThis.__hookEngine) {
|
|
40
|
+
globalThis.__hookEngine.register(event, handler);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (globalThis.__debug__) {
|
|
45
|
+
globalThis.__debug__.expose('plugins', Object.fromEntries(this._plugins.entries()), 'Plugin registry');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return this;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
getPlugins(entityName) {
|
|
52
|
+
return this._plugins.get(entityName) || null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
getConfig() {
|
|
56
|
+
return deepFreeze(deepClone(this.masterConfig));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
getRoles() {
|
|
60
|
+
return this._cached('roles:all', () => deepFreeze(deepClone(this.masterConfig.roles || {})));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
getRole(roleName) {
|
|
64
|
+
return this._cached(`role:${roleName}`, () => deepFreeze(deepClone(this._require('roles', roleName))));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
getDomains() {
|
|
68
|
+
return deepFreeze(deepClone(this.masterConfig.domains || {}));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
getHighlightPalette() {
|
|
72
|
+
return deepFreeze(deepClone(this.masterConfig.highlights?.palette || []));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
getSystemConfig() {
|
|
76
|
+
return deepFreeze(deepClone(this.masterConfig.system || {}));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
getRepeatIntervals() {
|
|
80
|
+
return deepFreeze(deepClone(this.masterConfig.repeat_intervals || { once: 'once', monthly: 'monthly', yearly: 'yearly' }));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
getAllEntities() {
|
|
84
|
+
return Object.keys(this.masterConfig.entities || {}).sort();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
getAllAutomations() {
|
|
88
|
+
return deepClone(this.masterConfig.automation?.schedules || []);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
getTheme() {
|
|
92
|
+
return this._cached('theme:all', () => deepFreeze(deepClone(this.masterConfig.theme || {})));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
getIcons() {
|
|
96
|
+
return this._cached('icons:all', () => deepFreeze(deepClone(this.masterConfig.icons || {})));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
getIcon(iconType, iconName) {
|
|
100
|
+
return this.masterConfig.icons?.[iconType]?.[iconName] || null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
getWorkflow(name) {
|
|
104
|
+
return this._resolved('workflow', 'workflows', name);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
getPermissionTemplate(name) {
|
|
108
|
+
return deepFreeze(deepClone(this._require('permission_templates', name)));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
getStatusEnum(name) {
|
|
112
|
+
return deepFreeze(deepClone(this._require('status_enums', name)));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
getStatuses(name) {
|
|
116
|
+
return this._cached(`statuses:${name}`, () => deepFreeze(deepClone(this._require('status_enums', name))));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
getStatus(entity, status) {
|
|
120
|
+
return this._cached(`status:${entity}:${status}`, () => deepFreeze(deepClone(this._require(`status_enums.${entity}_status`, status))));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
getEntity(name) {
|
|
124
|
+
return this._cached(`entity:${name}`, () => deepFreeze(deepClone(this._require('entities', name))));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
getValidationRule(name) {
|
|
128
|
+
return this._resolved('validation rule', 'validation', name);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
getDocumentTemplate(name) {
|
|
132
|
+
return this._resolved('document template', 'document_generation.templates', name);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
getIntegration(name) {
|
|
136
|
+
return deepFreeze(deepClone(this._require('integrations', name)));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
getThreshold(dotPath) {
|
|
140
|
+
const segments = dotPath.split('.');
|
|
141
|
+
let cur = this.masterConfig.thresholds;
|
|
142
|
+
for (const s of segments) {
|
|
143
|
+
if (cur == null) throw new Error(`[config] Threshold "${dotPath}" not found`);
|
|
144
|
+
cur = cur[s];
|
|
145
|
+
}
|
|
146
|
+
if (cur === undefined) throw new Error(`[config] Threshold "${dotPath}" not found`);
|
|
147
|
+
return typeof cur === 'object' ? deepFreeze(deepClone(cur)) : cur;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
getTimingConfig() {
|
|
151
|
+
return this._cached('thresholds:timing', () => deepFreeze(deepClone(this.getThreshold('timing'))));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
getSystemLimitsConfig() {
|
|
155
|
+
return this._cached('thresholds:system', () => deepFreeze(deepClone(this.getThreshold('system'))));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
getUIConfig() {
|
|
159
|
+
return this._cached('thresholds:ui', () => deepFreeze(deepClone(this.getThreshold('ui'))));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
getPollingConfig() {
|
|
163
|
+
return this._cached('thresholds:polling', () => deepFreeze(deepClone(this.getThreshold('polling'))));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
getWorkflowStages(wfName) {
|
|
167
|
+
return (this.masterConfig.workflows?.[wfName]?.stages || []).map(s => typeof s === 'string' ? s : s.name);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
getStageConfig(wfName, stageName) {
|
|
171
|
+
const stage = (this.masterConfig.workflows?.[wfName]?.stages || []).find(
|
|
172
|
+
s => (typeof s === 'string' ? s : s.name) === stageName
|
|
173
|
+
);
|
|
174
|
+
if (!stage) return null;
|
|
175
|
+
return deepFreeze(recursiveResolve(deepClone(typeof stage === 'string' ? { name: stage } : stage), this.masterConfig));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
getEntitiesForDomain(domain) {
|
|
179
|
+
return [...(this._require('domains', domain).entities || [])];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
isFeatureEnabled(name, ctx = {}) {
|
|
183
|
+
const f = this.masterConfig.features?.[name];
|
|
184
|
+
if (!f || f.enabled === false || f.deprecated) return false;
|
|
185
|
+
if (f.domain && ctx.domain && f.domain !== ctx.domain) return false;
|
|
186
|
+
if (f.workflow_stage && ctx.stage && f.workflow_stage !== ctx.stage) return false;
|
|
187
|
+
if (f.roles && ctx.role && !f.roles.includes(ctx.role)) return false;
|
|
188
|
+
if (f.requires && ctx.features && f.requires.some(r => !ctx.features.includes(r))) return false;
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
getFieldValue(entity, field) {
|
|
193
|
+
const spec = this.generateEntitySpec(entity);
|
|
194
|
+
if (!spec.fields?.[field]) throw new Error(`[config] Field "${field}" not found for entity "${entity}"`);
|
|
195
|
+
return deepFreeze(deepClone(spec.fields[field]));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
getRolePermissions(role, entity) {
|
|
199
|
+
return [...(this.generateEntitySpec(entity).permissions?.[role] || [])];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
canRoleDoAction(role, entity, action) {
|
|
203
|
+
const p = this.getRolePermissions(role, entity);
|
|
204
|
+
return p.includes(action) || p.includes('all');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
generateNotificationHandler(name) {
|
|
208
|
+
return deepFreeze(recursiveResolve(deepClone(this._require('notifications', name)), this.masterConfig));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
generateAutomationJob(name) {
|
|
212
|
+
const s = (this.masterConfig.automation?.schedules || []).find(s => s.name === name);
|
|
213
|
+
if (!s) throw new Error(`[config] Automation schedule "${name}" not found`);
|
|
214
|
+
return deepFreeze(recursiveResolve(deepClone(s), this.masterConfig));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
cacheSpec(entityName, spec) {
|
|
218
|
+
if (!entityName || !spec) throw new Error('[config] cacheSpec requires entityName and spec');
|
|
219
|
+
this.specCache.set(`spec:${entityName}`, deepFreeze(spec));
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
invalidateCache() {
|
|
224
|
+
this.specCache.clear();
|
|
225
|
+
this.masterConfig = null;
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
enableDebug(enabled = true) {
|
|
230
|
+
this.debugMode = enabled;
|
|
231
|
+
return this;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Generate complete entity specification from config
|
|
236
|
+
* This is the main method that builds the spec used by CRUD, UI, etc.
|
|
237
|
+
*/
|
|
238
|
+
generateEntitySpec(entityName) {
|
|
239
|
+
if (!entityName) throw new Error('[config] generateEntitySpec: entityName required');
|
|
240
|
+
|
|
241
|
+
const cacheKey = `spec:${entityName}`;
|
|
242
|
+
const cached = this.specCache.get(cacheKey);
|
|
243
|
+
if (cached) return deepFreeze(deepClone(cached));
|
|
244
|
+
|
|
245
|
+
const config = this.masterConfig;
|
|
246
|
+
let name = entityName;
|
|
247
|
+
let entityDef = config.entities?.[entityName] || (() => {
|
|
248
|
+
const match = Object.keys(config.entities || {}).find(k => {
|
|
249
|
+
const d = config.entities[k];
|
|
250
|
+
return (d.label_plural || d.label || k).toLowerCase() === entityName.toLowerCase();
|
|
251
|
+
});
|
|
252
|
+
if (match) {
|
|
253
|
+
name = match;
|
|
254
|
+
return config.entities[match];
|
|
255
|
+
}
|
|
256
|
+
})();
|
|
257
|
+
|
|
258
|
+
if (!entityDef) throw new Error(`[config] Entity "${entityName}" not found`);
|
|
259
|
+
|
|
260
|
+
entityDef = deepClone(entityDef);
|
|
261
|
+
const childrenObj = {};
|
|
262
|
+
(entityDef.children || []).forEach(c => { childrenObj[c] = { entity: c }; });
|
|
263
|
+
|
|
264
|
+
const spec = {
|
|
265
|
+
name,
|
|
266
|
+
label: entityDef.label || name,
|
|
267
|
+
labelPlural: entityDef.label_plural || entityDef.label || entityName,
|
|
268
|
+
icon: entityDef.icon || 'Circle',
|
|
269
|
+
order: entityDef.order || 999,
|
|
270
|
+
parent: entityDef.parent || null,
|
|
271
|
+
children: childrenObj,
|
|
272
|
+
computed_fields: entityDef.computed_fields || [],
|
|
273
|
+
has_timeline: entityDef.has_timeline || false,
|
|
274
|
+
has_roles: entityDef.has_roles || [],
|
|
275
|
+
has_pdf_viewer: entityDef.has_pdf_viewer || false,
|
|
276
|
+
has_collaboration: entityDef.has_collaboration || false,
|
|
277
|
+
has_tender_tracking: entityDef.has_tender_tracking || false,
|
|
278
|
+
has_notifications: entityDef.has_notifications || false,
|
|
279
|
+
has_authentication: entityDef.has_authentication || false,
|
|
280
|
+
has_google_sync: entityDef.has_google_sync || false,
|
|
281
|
+
recreation_enabled: entityDef.recreation_enabled || false,
|
|
282
|
+
recreation_intervals: entityDef.recreation_intervals || [],
|
|
283
|
+
immutable: entityDef.immutable || false,
|
|
284
|
+
immutable_strategy: entityDef.immutable_strategy || null,
|
|
285
|
+
state_machine: entityDef.state_machine || false,
|
|
286
|
+
row_access: entityDef.row_access || null,
|
|
287
|
+
embedded: entityDef.embedded || false,
|
|
288
|
+
system_entity: entityDef.system_entity || false,
|
|
289
|
+
fields: {},
|
|
290
|
+
options: {},
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
// Merge field overrides
|
|
294
|
+
if (entityDef.field_overrides) {
|
|
295
|
+
spec.field_overrides = recursiveResolve(entityDef.field_overrides, config);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Build fields from definition + overrides + plugin additions
|
|
299
|
+
const baseFields = entityDef.fields || {};
|
|
300
|
+
const plugin = this._plugins.get(entityName);
|
|
301
|
+
const pluginFields = plugin?.fields || {};
|
|
302
|
+
|
|
303
|
+
// Combine and process fields
|
|
304
|
+
const allFields = {
|
|
305
|
+
...baseFields,
|
|
306
|
+
...spec.field_overrides,
|
|
307
|
+
...pluginFields,
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
// Ensure required metadata (label, type defaults)
|
|
311
|
+
for (const [key, field] of Object.entries(allFields)) {
|
|
312
|
+
spec.fields[key] = {
|
|
313
|
+
type: field.type || 'text',
|
|
314
|
+
label: field.label || key,
|
|
315
|
+
required: field.required || false,
|
|
316
|
+
unique: field.unique || false,
|
|
317
|
+
readonly: field.readonly || field.readOnly || false,
|
|
318
|
+
hidden: field.hidden || false,
|
|
319
|
+
searchable: field.search || field.searchable || false,
|
|
320
|
+
sortable: field.sortable || field.sort || false,
|
|
321
|
+
default: field.default !== undefined ? field.default : null,
|
|
322
|
+
options: field.options || [],
|
|
323
|
+
ref: field.ref || null,
|
|
324
|
+
display: field.display || null,
|
|
325
|
+
auto: field.auto || null,
|
|
326
|
+
min: field.min,
|
|
327
|
+
max: field.max,
|
|
328
|
+
...field,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Permission matrix
|
|
333
|
+
if (entityDef.permission_template) {
|
|
334
|
+
const matrix = this.getPermissionTemplate(entityDef.permission_template);
|
|
335
|
+
const access = {};
|
|
336
|
+
const roleActions = {};
|
|
337
|
+
|
|
338
|
+
for (const [role, actions] of Object.entries(matrix)) {
|
|
339
|
+
if (!Array.isArray(actions)) continue;
|
|
340
|
+
roleActions[role] = [...actions];
|
|
341
|
+
actions.forEach(a => {
|
|
342
|
+
if (!access[a]) access[a] = [];
|
|
343
|
+
access[a].push(role);
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
spec.access = access;
|
|
348
|
+
spec.permissions = roleActions;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Workflow binding
|
|
352
|
+
if (entityDef.workflow) {
|
|
353
|
+
const wf = this.getWorkflow(entityDef.workflow);
|
|
354
|
+
spec.workflow = wf;
|
|
355
|
+
spec.workflowDef = wf;
|
|
356
|
+
if (wf?.stages && Array.isArray(wf.stages)) {
|
|
357
|
+
const stagesObj = {};
|
|
358
|
+
wf.stages.forEach(s => {
|
|
359
|
+
stagesObj[typeof s === 'string' ? s : s.name] = s;
|
|
360
|
+
});
|
|
361
|
+
entityDef.stages = stagesObj;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// List options
|
|
366
|
+
if (entityDef.list) {
|
|
367
|
+
spec.list = {
|
|
368
|
+
defaultSort: entityDef.list.defaultSort || { field: 'created_at', dir: 'desc' },
|
|
369
|
+
pageSize: entityDef.list.pageSize || 50,
|
|
370
|
+
filters: entityDef.list.filters || [],
|
|
371
|
+
...entityDef.list,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
this.specCache.set(cacheKey, deepFreeze(spec));
|
|
376
|
+
return spec;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Internal helpers
|
|
380
|
+
|
|
381
|
+
_cached(key, fn) {
|
|
382
|
+
const cached = this.specCache.get(key);
|
|
383
|
+
if (cached) return deepFreeze(deepClone(cached));
|
|
384
|
+
const value = fn();
|
|
385
|
+
this.specCache.set(key, value);
|
|
386
|
+
return value;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
_require(section, key) {
|
|
390
|
+
const sectionData = this.masterConfig[section];
|
|
391
|
+
if (!sectionData) throw new Error(`[config] Missing section: ${section}`);
|
|
392
|
+
const value = sectionData[key];
|
|
393
|
+
if (value === undefined) throw new Error(`[config] Key "${key}" not found in ${section}`);
|
|
394
|
+
return value;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
_resolved(type, section, key) {
|
|
398
|
+
const value = this._require(section, key);
|
|
399
|
+
return deepFreeze(recursiveResolve(deepClone(value), this.masterConfig));
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Get or create singleton config engine
|
|
405
|
+
*/
|
|
406
|
+
let _singleton = null;
|
|
407
|
+
|
|
408
|
+
export function getConfigEngineSync() {
|
|
409
|
+
if (!_singleton) {
|
|
410
|
+
throw new Error('ConfigEngine not initialized. Call initConfig() first.');
|
|
411
|
+
}
|
|
412
|
+
return _singleton;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export async function getConfigEngine() {
|
|
416
|
+
return getConfigEngineSync();
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Initialize config engine from config source
|
|
421
|
+
* @param {string|object} configSource
|
|
422
|
+
* @returns {ConfigGeneratorEngine}
|
|
423
|
+
*/
|
|
424
|
+
export async function initConfig(configSource) {
|
|
425
|
+
const { loadConfig } = await import('../config/config-loader.js');
|
|
426
|
+
const { validateConfig, getConfigWithDefaults } = await import('../config/config-loader.js');
|
|
427
|
+
|
|
428
|
+
const rawConfig = await loadConfig(configSource);
|
|
429
|
+
const config = getConfigWithDefaults(rawConfig);
|
|
430
|
+
const errors = validateConfig(config);
|
|
431
|
+
|
|
432
|
+
if (errors.length > 0) {
|
|
433
|
+
throw new Error(`Configuration validation failed:\n- ${errors.join('\n- ')}`);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
_singleton = new ConfigGeneratorEngine(config);
|
|
437
|
+
return _singleton;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Reset singleton (for hot reload)
|
|
442
|
+
*/
|
|
443
|
+
export function resetConfigEngine() {
|
|
444
|
+
_singleton = null;
|
|
445
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration Helpers - Utilities for config processing
|
|
3
|
+
* LRUCache, deep cloning, freezing, recursive resolution
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* LRU Cache implementation
|
|
8
|
+
*/
|
|
9
|
+
export class LRUCache {
|
|
10
|
+
constructor(maxSize = 100) {
|
|
11
|
+
this.maxSize = maxSize;
|
|
12
|
+
this.cache = new Map();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
get(key) {
|
|
16
|
+
if (!this.cache.has(key)) return undefined;
|
|
17
|
+
const value = this.cache.get(key);
|
|
18
|
+
this.cache.delete(key);
|
|
19
|
+
this.cache.set(key, value);
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
set(key, value) {
|
|
24
|
+
if (this.cache.has(key)) {
|
|
25
|
+
this.cache.delete(key);
|
|
26
|
+
} else if (this.cache.size >= this.maxSize) {
|
|
27
|
+
const firstKey = this.cache.keys().next().value;
|
|
28
|
+
this.cache.delete(firstKey);
|
|
29
|
+
}
|
|
30
|
+
this.cache.set(key, value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
clear() {
|
|
34
|
+
this.cache.clear();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
get size() {
|
|
38
|
+
return this.cache.size;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Deep freeze object (immutable)
|
|
44
|
+
*/
|
|
45
|
+
export function deepFreeze(obj) {
|
|
46
|
+
if (obj === null || typeof obj !== 'object') return obj;
|
|
47
|
+
if (Object.isFrozen(obj)) return obj;
|
|
48
|
+
|
|
49
|
+
Object.freeze(obj);
|
|
50
|
+
Object.getOwnPropertyNames(obj).forEach(prop => {
|
|
51
|
+
if (obj[prop] && typeof obj[prop] === 'object' && !Object.isFrozen(obj[prop])) {
|
|
52
|
+
deepFreeze(obj[prop]);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
return obj;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Deep clone object
|
|
60
|
+
*/
|
|
61
|
+
export function deepClone(obj) {
|
|
62
|
+
if (obj === null || typeof obj !== 'object') return obj;
|
|
63
|
+
if (Array.isArray(obj)) return obj.map(deepClone);
|
|
64
|
+
|
|
65
|
+
const cloned = {};
|
|
66
|
+
for (const key of Object.keys(obj)) {
|
|
67
|
+
cloned[key] = deepClone(obj[key]);
|
|
68
|
+
}
|
|
69
|
+
return cloned;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Recursively resolve string template references in config
|
|
74
|
+
* Replaces ${ref.path} with value from config
|
|
75
|
+
*/
|
|
76
|
+
export function recursiveResolve(value, config) {
|
|
77
|
+
if (typeof value === 'string') {
|
|
78
|
+
return value.replace(/\$\{([^}]+)\}/g, (_, path) => {
|
|
79
|
+
const segments = path.split('.');
|
|
80
|
+
let current = config;
|
|
81
|
+
for (const seg of segments) {
|
|
82
|
+
if (current && typeof current === 'object' && seg in current) {
|
|
83
|
+
current = current[seg];
|
|
84
|
+
} else {
|
|
85
|
+
return _; // Keep original if not found
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return typeof current === 'undefined' ? _ : current;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (Array.isArray(value)) {
|
|
93
|
+
return value.map(item => recursiveResolve(item, config));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (value && typeof value === 'object') {
|
|
97
|
+
const resolved = {};
|
|
98
|
+
for (const [k, v] of Object.entries(value)) {
|
|
99
|
+
resolved[k] = recursiveResolve(v, config);
|
|
100
|
+
}
|
|
101
|
+
return resolved;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return value;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Merge objects deeply
|
|
109
|
+
*/
|
|
110
|
+
export function deepMerge(target, source) {
|
|
111
|
+
const output = { ...target };
|
|
112
|
+
for (const key of Object.keys(source)) {
|
|
113
|
+
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
|
114
|
+
output[key] = deepMerge(target[key] || {}, source[key]);
|
|
115
|
+
} else {
|
|
116
|
+
output[key] = source[key];
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return output;
|
|
120
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
class ConnectionGuard {
|
|
2
|
+
constructor(config) {
|
|
3
|
+
this.config = config
|
|
4
|
+
this.ipConnections = new Map()
|
|
5
|
+
this.messageRates = new Map()
|
|
6
|
+
this.pingIntervalId = null
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
checkConnectionLimit(ip) {
|
|
10
|
+
const count = this.ipConnections.get(ip) || 0
|
|
11
|
+
return count < this.config.maxConnectionsPerIP
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
trackIPConnection(ip) {
|
|
15
|
+
this.ipConnections.set(ip, (this.ipConnections.get(ip) || 0) + 1)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
untrackIPConnection(ip) {
|
|
19
|
+
const count = this.ipConnections.get(ip) || 0
|
|
20
|
+
if (count <= 1) {
|
|
21
|
+
this.ipConnections.delete(ip)
|
|
22
|
+
} else {
|
|
23
|
+
this.ipConnections.set(ip, count - 1)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
checkRateLimit(clientId) {
|
|
28
|
+
const now = Date.now()
|
|
29
|
+
const rate = this.messageRates.get(clientId) || { count: 0, window: now }
|
|
30
|
+
|
|
31
|
+
if (now - rate.window > 1000) {
|
|
32
|
+
rate.count = 1
|
|
33
|
+
rate.window = now
|
|
34
|
+
} else {
|
|
35
|
+
rate.count++
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
this.messageRates.set(clientId, rate)
|
|
39
|
+
return rate.count <= this.config.messageRateLimit
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
clearRateLimit(clientId) {
|
|
43
|
+
this.messageRates.delete(clientId)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
startPingInterval(clients, onDead) {
|
|
47
|
+
this.pingIntervalId = setInterval(() => {
|
|
48
|
+
for (const [clientId, client] of clients) {
|
|
49
|
+
if (!client.alive) {
|
|
50
|
+
client.ws.terminate()
|
|
51
|
+
onDead(clientId)
|
|
52
|
+
} else {
|
|
53
|
+
client.alive = false
|
|
54
|
+
client.ws.ping()
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}, this.config.pingInterval)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
stopPingInterval() {
|
|
61
|
+
if (this.pingIntervalId) {
|
|
62
|
+
clearInterval(this.pingIntervalId)
|
|
63
|
+
this.pingIntervalId = null
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
generateClientId() {
|
|
68
|
+
return `client_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
destroy() {
|
|
72
|
+
this.stopPingInterval()
|
|
73
|
+
this.ipConnections.clear()
|
|
74
|
+
this.messageRates.clear()
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export { ConnectionGuard }
|
|
79
|
+
export default ConnectionGuard
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { AppError } from '@/lib/errors';
|
|
2
|
+
import { HTTP } from '@/config/constants';
|
|
3
|
+
import { now } from '@/lib/database-core';
|
|
4
|
+
|
|
5
|
+
const ACTION_FIELD_MAPS = {
|
|
6
|
+
resolve_highlight: { status: 'resolved', color: 'green' },
|
|
7
|
+
reopen_highlight: { status: 'unresolved', color: 'grey', resolved_at: null, resolved_by: null, resolution_notes: null },
|
|
8
|
+
change_status: (data, user) => ({ status: data.status || data.toStatus, status_changed_at: now(), status_changed_by: user.id }),
|
|
9
|
+
respond: (data, user) => ({ status: 'responded', response: data.response, responded_at: now(), responded_by: user.id }),
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function resolveActionUpdate(action, data, user, record) {
|
|
13
|
+
const mapped = ACTION_FIELD_MAPS[action];
|
|
14
|
+
if (typeof mapped === 'function') return mapped(data, user);
|
|
15
|
+
if (mapped) {
|
|
16
|
+
const result = { ...mapped };
|
|
17
|
+
if (action === 'resolve_highlight') {
|
|
18
|
+
result.resolved_at = now();
|
|
19
|
+
result.resolved_by = user.id;
|
|
20
|
+
result.resolution_notes = data.notes || data.resolution_notes || '';
|
|
21
|
+
}
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
return data;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function validateActionPrecondition(action, record) {
|
|
28
|
+
if (action === 'resolve_highlight' && record.status === 'resolved') {
|
|
29
|
+
throw new AppError('Highlight already resolved', 'BAD_REQUEST', HTTP.BAD_REQUEST);
|
|
30
|
+
}
|
|
31
|
+
if (action === 'reopen_highlight' && record.status !== 'resolved') {
|
|
32
|
+
throw new AppError('Highlight not resolved', 'BAD_REQUEST', HTTP.BAD_REQUEST);
|
|
33
|
+
}
|
|
34
|
+
}
|