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,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Engine - State machine management for entity lifecycle transitions
|
|
3
|
+
* Handles validation of state transitions and available actions
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { get, update, create } from './query-engine.js';
|
|
7
|
+
import { getConfigEngineSync } from './config-generator-engine.js';
|
|
8
|
+
import { AppError } from './error-handler.js';
|
|
9
|
+
import { HTTP } from '../config/constants.js';
|
|
10
|
+
|
|
11
|
+
const LOCKOUT_SECONDS = 300; // 5 minutes default
|
|
12
|
+
const workflowCache = new Map();
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Get workflow definition from config
|
|
16
|
+
* @param {string} workflowName
|
|
17
|
+
* @returns {object}
|
|
18
|
+
*/
|
|
19
|
+
function getWorkflowDef(workflowName) {
|
|
20
|
+
if (workflowCache.has(workflowName)) {
|
|
21
|
+
return workflowCache.get(workflowName);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const config = getConfigEngineSync().getConfig();
|
|
25
|
+
const wf = config?.workflows?.[workflowName];
|
|
26
|
+
if (!wf) throw new Error(`Workflow "${workflowName}" not found in config`);
|
|
27
|
+
|
|
28
|
+
const stageMap = {};
|
|
29
|
+
if (wf.stages) {
|
|
30
|
+
for (const stage of wf.stages) {
|
|
31
|
+
stageMap[stage.name] = stage;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (wf.states) {
|
|
35
|
+
for (const state of wf.states) {
|
|
36
|
+
stageMap[state.name] = state;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const def = { ...wf, stageMap };
|
|
41
|
+
workflowCache.set(workflowName, def);
|
|
42
|
+
return def;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Validate a state transition
|
|
47
|
+
* @param {string} workflowName
|
|
48
|
+
* @param {string} fromState
|
|
49
|
+
* @param {string} toState
|
|
50
|
+
* @param {object} user
|
|
51
|
+
* @returns {{forward: boolean, backward: boolean}}
|
|
52
|
+
* @throws {AppError} if transition invalid
|
|
53
|
+
*/
|
|
54
|
+
export function validateTransition(workflowName, fromState, toState, user) {
|
|
55
|
+
const def = getWorkflowDef(workflowName);
|
|
56
|
+
const fromCfg = def.stageMap[fromState];
|
|
57
|
+
const toCfg = def.stageMap[toState];
|
|
58
|
+
|
|
59
|
+
if (!fromCfg) throw new AppError(`Invalid current state: ${fromState}`, 'INVALID_STATE', HTTP.BAD_REQUEST);
|
|
60
|
+
if (!toCfg) throw new AppError(`Invalid target state: ${toState}`, 'INVALID_STATE', HTTP.BAD_REQUEST);
|
|
61
|
+
|
|
62
|
+
const forward = fromCfg.forward || [];
|
|
63
|
+
const backward = fromCfg.backward || [];
|
|
64
|
+
|
|
65
|
+
if (!forward.includes(toState) && !backward.includes(toState)) {
|
|
66
|
+
throw new AppError(
|
|
67
|
+
`Cannot transition from "${fromState}" to "${toState}". Allowed: ${[...forward, ...backward].join(', ') || 'none'}`,
|
|
68
|
+
'TRANSITION_INVALID',
|
|
69
|
+
HTTP.BAD_REQUEST
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Role requirement check
|
|
74
|
+
const requiresRole = toCfg.requires_role || [];
|
|
75
|
+
if (requiresRole.length > 0 && user && !requiresRole.includes(user.role)) {
|
|
76
|
+
throw new AppError(`Role "${user.role}" cannot enter state "${toState}"`, 'INSUFFICIENT_PERMISSIONS', HTTP.FORBIDDEN);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Entry constraint (e.g., partner_only)
|
|
80
|
+
if (toCfg.entry === 'partner_only' && user?.role !== 'partner') {
|
|
81
|
+
throw new AppError(`Only partners can enter "${toState}"`, 'ENTRY_CONSTRAINT', HTTP.FORBIDDEN);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Readonly check
|
|
85
|
+
if (toCfg.readonly) {
|
|
86
|
+
throw new AppError(`State "${toState}" is read-only`, 'STATE_READONLY', HTTP.FORBIDDEN);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
forward: forward.includes(toState),
|
|
91
|
+
backward: backward.includes(toState),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Get available transitions for current state
|
|
97
|
+
* @param {string} workflowName
|
|
98
|
+
* @param {string} currentState
|
|
99
|
+
* @param {object} user
|
|
100
|
+
* @param {object} [record]
|
|
101
|
+
* @returns {Array<{stage: string, label: string, forward: boolean, backward: boolean}>}
|
|
102
|
+
*/
|
|
103
|
+
export function getAvailableTransitions(workflowName, currentState, user, record = null) {
|
|
104
|
+
const def = getWorkflowDef(workflowName);
|
|
105
|
+
const currentCfg = def.stageMap[currentState];
|
|
106
|
+
if (!currentCfg) return [];
|
|
107
|
+
|
|
108
|
+
const available = [];
|
|
109
|
+
const candidates = [...(currentCfg.forward || []), ...(currentCfg.backward || [])];
|
|
110
|
+
const currentOrder = currentCfg.order || 0;
|
|
111
|
+
|
|
112
|
+
for (const stateName of candidates) {
|
|
113
|
+
try {
|
|
114
|
+
// Lockout check based on last_transition_at
|
|
115
|
+
if (record?.last_transition_at) {
|
|
116
|
+
const elapsed = (Date.now() / 1000) - record.last_transition_at;
|
|
117
|
+
if (elapsed < LOCKOUT_SECONDS) continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
validateTransition(workflowName, currentState, stateName, user);
|
|
121
|
+
const cfg = def.stageMap[stateName];
|
|
122
|
+
available.push({
|
|
123
|
+
stage: stateName,
|
|
124
|
+
label: cfg.label || stateName,
|
|
125
|
+
forward: (cfg.order || 0) > currentOrder,
|
|
126
|
+
backward: (cfg.order || 0) < currentOrder,
|
|
127
|
+
});
|
|
128
|
+
} catch {
|
|
129
|
+
// Skip invalid transitions
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return available;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Get transition lockout status
|
|
138
|
+
* @param {object} record
|
|
139
|
+
* @returns {{inLockout: boolean, minutesRemaining: number, failedGates: Array}}
|
|
140
|
+
*/
|
|
141
|
+
export function getTransitionStatus(record) {
|
|
142
|
+
let inLockout = false;
|
|
143
|
+
let minutesRemaining = 0;
|
|
144
|
+
|
|
145
|
+
if (record?.last_transition_at) {
|
|
146
|
+
const elapsed = (Date.now() / 1000) - record.last_transition_at;
|
|
147
|
+
if (elapsed < LOCKOUT_SECONDS) {
|
|
148
|
+
inLockout = true;
|
|
149
|
+
minutesRemaining = Math.ceil((LOCKOUT_SECONDS - elapsed) / 60);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { inLockout, minutesRemaining, failedGates: [] };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Execute a state transition
|
|
158
|
+
* @param {string} entityType
|
|
159
|
+
* @param {string} entityId
|
|
160
|
+
* @param {string} workflowName
|
|
161
|
+
* @param {string} toState
|
|
162
|
+
* @param {object} user
|
|
163
|
+
* @param {string} [reason='']
|
|
164
|
+
* @returns {Promise<object>} Updated record
|
|
165
|
+
*/
|
|
166
|
+
export async function transition(entityType, entityId, workflowName, toState, user, reason = '') {
|
|
167
|
+
// Validate transition
|
|
168
|
+
const record = get(entityType, entityId);
|
|
169
|
+
if (!record) throw new AppError('Record not found', 'NOT_FOUND', HTTP.NOT_FOUND);
|
|
170
|
+
|
|
171
|
+
validateTransition(workflowName, record.status || record.stage, toState, user);
|
|
172
|
+
|
|
173
|
+
// Update record
|
|
174
|
+
const updates = {
|
|
175
|
+
status: toState,
|
|
176
|
+
updated_at: Math.floor(Date.now() / 1000),
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
if (reason) {
|
|
180
|
+
updates.transition_reason = reason;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Dynamically import write engine to avoid circular dependency
|
|
184
|
+
const { update: updateRecord } = await import('./query-engine-write.js');
|
|
185
|
+
const updated = updateRecord(entityType, entityId, updates, user);
|
|
186
|
+
|
|
187
|
+
// Execute hooks
|
|
188
|
+
executeHook(`transition:${entityType}`, {
|
|
189
|
+
entity: entityType,
|
|
190
|
+
id: entityId,
|
|
191
|
+
from: record.status,
|
|
192
|
+
to: toState,
|
|
193
|
+
user,
|
|
194
|
+
record: updated,
|
|
195
|
+
}).catch(err => console.error('transition hook error:', err));
|
|
196
|
+
|
|
197
|
+
return updated;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Get state field name for workflow (usually 'status' or 'stage')
|
|
202
|
+
* @param {string} workflowName
|
|
203
|
+
* @returns {string}
|
|
204
|
+
*/
|
|
205
|
+
export function getStateField(workflowName) {
|
|
206
|
+
const def = getWorkflowDef(workflowName);
|
|
207
|
+
return def.state_field || 'status';
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Get stage labels mapping
|
|
212
|
+
* @param {string} workflowName
|
|
213
|
+
* @returns {object}
|
|
214
|
+
*/
|
|
215
|
+
export function getStageLabels(workflowName) {
|
|
216
|
+
const def = getWorkflowDef(workflowName);
|
|
217
|
+
const labels = {};
|
|
218
|
+
if (def.stages) {
|
|
219
|
+
for (const stage of def.stages) {
|
|
220
|
+
labels[stage.name] = stage.label || stage.name;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return labels;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Get locked fields for a state (readonly fields)
|
|
228
|
+
* @param {string} workflowName
|
|
229
|
+
* @param {string} state
|
|
230
|
+
* @returns {string[]}
|
|
231
|
+
*/
|
|
232
|
+
export function getStateLocks(workflowName, state) {
|
|
233
|
+
const def = getWorkflowDef(workflowName);
|
|
234
|
+
const stage = def.stageMap[state];
|
|
235
|
+
if (!stage) return [];
|
|
236
|
+
return stage.locks || [];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Get actions available in a state
|
|
241
|
+
* @param {string} workflowName
|
|
242
|
+
* @param {string} state
|
|
243
|
+
* @returns {string[]}
|
|
244
|
+
*/
|
|
245
|
+
export function getStateActions(workflowName, state) {
|
|
246
|
+
const def = getWorkflowDef(workflowName);
|
|
247
|
+
const stage = def.stageMap[state];
|
|
248
|
+
if (!stage) return [];
|
|
249
|
+
return stage.actions || [];
|
|
250
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thatcher HTTP Server - Simplified generic CRUD router
|
|
3
|
+
* Provides out-of-the-box API for all entities
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import http from 'http';
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'url';
|
|
8
|
+
import * as thatcherLib from '../index.js';
|
|
9
|
+
|
|
10
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
|
|
12
|
+
export function createServer(options) {
|
|
13
|
+
const { thatcher, config, configEngine, port, host } = options;
|
|
14
|
+
const PORT = port || 3000;
|
|
15
|
+
const SERVER_START = Date.now();
|
|
16
|
+
|
|
17
|
+
let systemInitialized = false;
|
|
18
|
+
const moduleCache = new Map();
|
|
19
|
+
|
|
20
|
+
// Debug registry
|
|
21
|
+
globalThis.__debug__ = globalThis.__debug__ || {};
|
|
22
|
+
globalThis.__debug__.moduleCache = { get size() { return moduleCache.size; }, entries: () => [...moduleCache.keys()] };
|
|
23
|
+
globalThis.__debug__.activeRequests = { count: 0 };
|
|
24
|
+
globalThis.__debug__.hooks = null;
|
|
25
|
+
globalThis.__debug__.serverStart = SERVER_START;
|
|
26
|
+
globalThis.__debug__.uptime = () => Date.now() - SERVER_START;
|
|
27
|
+
|
|
28
|
+
// Load module with caching
|
|
29
|
+
const load = (p) => {
|
|
30
|
+
const cached = moduleCache.get(p);
|
|
31
|
+
if (cached) return cached;
|
|
32
|
+
return import(`file://${p}?t=${Date.now()}`).then(mod => {
|
|
33
|
+
moduleCache.set(p, mod);
|
|
34
|
+
return mod;
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const server = http.createServer(async (req, res) => {
|
|
39
|
+
const t0 = Date.now();
|
|
40
|
+
globalThis.__debug__.activeRequests.count++;
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
// Initialize on first request
|
|
44
|
+
if (!systemInitialized) {
|
|
45
|
+
const { loadPlugins } = await load(path.join(__dirname, '../plugins/index.js'));
|
|
46
|
+
await loadPlugins(configEngine);
|
|
47
|
+
systemInitialized = true;
|
|
48
|
+
console.log('[Server] System ready');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
52
|
+
const pathname = url.pathname;
|
|
53
|
+
|
|
54
|
+
// API routes
|
|
55
|
+
if (pathname.startsWith('/api/')) {
|
|
56
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
57
|
+
const parts = pathname.slice(5).split('/').filter(Boolean); // remove /api/
|
|
58
|
+
|
|
59
|
+
if (parts.length === 0) {
|
|
60
|
+
res.writeHead(400);
|
|
61
|
+
res.end(JSON.stringify({ error: 'Entity required' }));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const entity = parts[0];
|
|
66
|
+
const id = parts[1] || null;
|
|
67
|
+
const action = parts[2] || null;
|
|
68
|
+
|
|
69
|
+
// Check if user has custom route for this
|
|
70
|
+
const userRoutePath = path.join(process.cwd(), 'app/api', ...parts, 'route.js');
|
|
71
|
+
const routeExists = await fileExists(userRoutePath);
|
|
72
|
+
|
|
73
|
+
if (routeExists) {
|
|
74
|
+
const mod = await load(userRoutePath);
|
|
75
|
+
const handler = mod[req.method] || mod.default;
|
|
76
|
+
if (handler) {
|
|
77
|
+
const response = await handler(req, { params: { entity, id, action, path: parts.slice(1) }, configEngine });
|
|
78
|
+
return sendResponse(res, response);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Fall back to generic CRUD
|
|
83
|
+
return await handleGenericCrud(req, res, entity, id, action, thatcher);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Static file serving (simplified)
|
|
87
|
+
if (serveStaticFile(pathname, req, res, load)) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Page routing (UI)
|
|
92
|
+
if (!pathname.startsWith('/api/')) {
|
|
93
|
+
try {
|
|
94
|
+
const { handlePage } = await load(path.join(__dirname, '../ui/page-handler.js'));
|
|
95
|
+
const html = await handlePage(pathname, req, res, configEngine, thatcher);
|
|
96
|
+
if (html) {
|
|
97
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
98
|
+
res.setHeader('Content-Length', Buffer.byteLength(html, 'utf-8'));
|
|
99
|
+
res.writeHead(200);
|
|
100
|
+
res.end(html);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
} catch (e) {
|
|
104
|
+
console.error('[Page]', e.message);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 404
|
|
109
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
110
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
111
|
+
|
|
112
|
+
} catch (err) {
|
|
113
|
+
console.error('[Server]', err.message);
|
|
114
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
115
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
116
|
+
} finally {
|
|
117
|
+
globalThis.__debug__.activeRequests.count--;
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return server;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Serve static file if exists
|
|
126
|
+
*/
|
|
127
|
+
async function serveStaticFile(pathname, req, res, load) {
|
|
128
|
+
if (pathname === '/' || pathname === '/index.html') {
|
|
129
|
+
// Could serve SPA
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
// Try to serve from public/ or static/
|
|
133
|
+
const filePath = path.join(process.cwd(), 'public', pathname);
|
|
134
|
+
try {
|
|
135
|
+
if (await fileExists(filePath)) {
|
|
136
|
+
const content = await fs.promises.readFile(filePath);
|
|
137
|
+
const ext = path.extname(filePath);
|
|
138
|
+
const mime = {
|
|
139
|
+
'.js': 'application/javascript',
|
|
140
|
+
'.css': 'text/css',
|
|
141
|
+
'.html': 'text/html',
|
|
142
|
+
'.png': 'image/png',
|
|
143
|
+
'.jpg': 'image/jpeg',
|
|
144
|
+
'.svg': 'image/svg+xml',
|
|
145
|
+
}[ext] || 'text/plain';
|
|
146
|
+
res.setHeader('Content-Type', mime);
|
|
147
|
+
res.writeHead(200);
|
|
148
|
+
res.end(content);
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
} catch {}
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* File exists check
|
|
157
|
+
*/
|
|
158
|
+
function fileExists(p) {
|
|
159
|
+
try {
|
|
160
|
+
return fs.existsSync(p);
|
|
161
|
+
} catch {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Send response appropriately
|
|
168
|
+
*/
|
|
169
|
+
async function sendResponse(res, response) {
|
|
170
|
+
const headerObj = {};
|
|
171
|
+
if (response.headers) {
|
|
172
|
+
for (const [k, v] of response.headers) {
|
|
173
|
+
headerObj[k] = v;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (response.status >= 300 && response.status < 400) {
|
|
178
|
+
res.writeHead(response.status, headerObj);
|
|
179
|
+
res.end();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const ct = (headerObj['Content-Type'] || '').toLowerCase();
|
|
184
|
+
const isJson = !ct || ct.includes('json');
|
|
185
|
+
let bodyOut;
|
|
186
|
+
|
|
187
|
+
if (isJson) {
|
|
188
|
+
bodyOut = JSON.stringify(await response.json());
|
|
189
|
+
if (!headerObj['Content-Type']) headerObj['Content-Type'] = 'application/json; charset=utf-8';
|
|
190
|
+
} else {
|
|
191
|
+
bodyOut = await response.text();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
headerObj['Content-Length'] = Buffer.byteLength(bodyOut).toString();
|
|
195
|
+
res.writeHead(response.status, headerObj);
|
|
196
|
+
res.end(bodyOut);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Generic CRUD handler
|
|
201
|
+
*/
|
|
202
|
+
async function handleGenericCrud(req, res, entity, id, action, thatcher) {
|
|
203
|
+
// Simple auth: get from cookie or header
|
|
204
|
+
let user = null;
|
|
205
|
+
// In a real implementation, we'd decode session token
|
|
206
|
+
// For now, default to system user for testing
|
|
207
|
+
user = { id: 'system', role: 'admin' };
|
|
208
|
+
|
|
209
|
+
const { configEngine } = thatcher;
|
|
210
|
+
let spec;
|
|
211
|
+
try {
|
|
212
|
+
spec = configEngine.generateEntitySpec(entity);
|
|
213
|
+
} catch (e) {
|
|
214
|
+
res.writeHead(404);
|
|
215
|
+
res.end(JSON.stringify({ error: `Entity '${entity}' not found` }));
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Permission check would go here via thatcher.can(user, spec, action)
|
|
220
|
+
|
|
221
|
+
let body;
|
|
222
|
+
try {
|
|
223
|
+
body = await readBody(req);
|
|
224
|
+
} catch (e) {
|
|
225
|
+
res.writeHead(400);
|
|
226
|
+
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
let result;
|
|
232
|
+
let status = 200;
|
|
233
|
+
|
|
234
|
+
switch (req.method) {
|
|
235
|
+
case 'GET':
|
|
236
|
+
if (id) {
|
|
237
|
+
result = await thatcher.get(entity, id);
|
|
238
|
+
if (!result) {
|
|
239
|
+
res.writeHead(404);
|
|
240
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
} else {
|
|
244
|
+
result = await thatcher.list(entity);
|
|
245
|
+
}
|
|
246
|
+
break;
|
|
247
|
+
|
|
248
|
+
case 'POST':
|
|
249
|
+
result = await thatcher.create(entity, body, user);
|
|
250
|
+
status = 201;
|
|
251
|
+
break;
|
|
252
|
+
|
|
253
|
+
case 'PUT':
|
|
254
|
+
case 'PATCH':
|
|
255
|
+
if (!id) {
|
|
256
|
+
res.writeHead(400);
|
|
257
|
+
res.end(JSON.stringify({ error: 'ID required' }));
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
result = await thatcher.update(entity, id, body, user);
|
|
261
|
+
break;
|
|
262
|
+
|
|
263
|
+
case 'DELETE':
|
|
264
|
+
if (!id) {
|
|
265
|
+
res.writeHead(400);
|
|
266
|
+
res.end(JSON.stringify({ error: 'ID required' }));
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
await thatcher.delete(entity, id);
|
|
270
|
+
res.writeHead(204);
|
|
271
|
+
res.end();
|
|
272
|
+
return;
|
|
273
|
+
|
|
274
|
+
default:
|
|
275
|
+
res.writeHead(405);
|
|
276
|
+
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
281
|
+
res.end(JSON.stringify(result));
|
|
282
|
+
} catch (err) {
|
|
283
|
+
console.error('[API]', err.message);
|
|
284
|
+
res.writeHead(500);
|
|
285
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Read request body
|
|
291
|
+
*/
|
|
292
|
+
async function readBody(req) {
|
|
293
|
+
return new Promise((resolve, reject) => {
|
|
294
|
+
let data = '';
|
|
295
|
+
req.on('data', chunk => data += chunk);
|
|
296
|
+
req.on('end', () => {
|
|
297
|
+
try {
|
|
298
|
+
resolve(JSON.parse(data));
|
|
299
|
+
} catch {
|
|
300
|
+
resolve(data);
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
req.on('error', reject);
|
|
304
|
+
});
|
|
305
|
+
}
|