thatcher 1.0.7 → 1.0.34
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/README.md +4 -16
- package/package.json +12 -18
- package/src/adapters/google-auth.js +4 -43
- package/src/adapters/google-drive.js +12 -67
- package/src/app/api/audit/dashboard/route.js +14 -11
- package/src/app/api/audit/logs/route.js +2 -2
- package/src/app/api/audit/permissions/[id]/route.js +6 -3
- package/src/app/api/audit/permissions/route.js +10 -7
- package/src/app/api/audit/permissions/stats/route.js +8 -5
- package/src/app/api/audit/route.js +4 -4
- package/src/app/api/audit/stats/route.js +1 -1
- package/src/app/api/auth/google/callback/route.js +5 -2
- package/src/app/api/auth/google/route.js +4 -1
- package/src/app/api/auth/login/route.js +5 -2
- package/src/app/api/auth/mwr-bridge/route.js +11 -21
- package/src/app/api/auth/password-reset/route.js +15 -14
- package/src/app/api/cron/trigger/route.js +4 -1
- package/src/app/api/debug/[[...path]]/route.js +6 -5
- package/src/app/api/debug/sqlite/route.js +7 -12
- package/src/app/api/debug/sync/route.js +5 -5
- package/src/app/api/domains/route.js +4 -1
- package/src/app/api/email/allocate/batch/route.js +19 -29
- package/src/app/api/email/allocate/route.js +16 -17
- package/src/app/api/email/receive/route.js +31 -42
- package/src/app/api/email/send/route.js +28 -16
- package/src/app/api/email/unallocated/route.js +10 -13
- package/src/app/api/files/[id]/route.js +4 -1
- package/src/app/api/formula/[[...path]]/route.js +0 -4
- package/src/app/api/health/route.js +10 -9
- package/src/app/api/metrics/route.js +1 -1
- package/src/cli.js +4 -4
- package/src/config/config-loader.js +1 -21
- package/src/config/constants.js +0 -35
- package/src/config/env.js +0 -30
- package/src/config/spec-helpers.js +6 -95
- package/src/engine.js +23 -0
- package/src/engine.server.js +8 -78
- package/src/index.js +42 -67
- package/src/lib/alert-manager.js +7 -3
- package/src/lib/api-helpers.js +4 -3
- package/src/lib/api.js +10 -10
- package/src/lib/audit-logger-enhanced.js +77 -83
- package/src/lib/auth-middleware.js +1 -32
- package/src/lib/auth-route-helpers.js +5 -0
- package/src/lib/busybase-adapter.js +0 -5
- package/src/lib/busybase-audit-reads.js +142 -0
- package/src/lib/busybase-audit.js +41 -0
- package/src/lib/busybase-lucia-adapter.js +72 -0
- package/src/lib/busybase-store.js +338 -0
- package/src/lib/config-field-helpers.js +0 -17
- package/src/lib/config-generator-engine.js +19 -36
- package/src/lib/config-helpers.js +0 -21
- package/src/lib/crud-action-helpers.js +2 -2
- package/src/lib/crud-factory.js +1 -16
- package/src/lib/crud-handlers.js +17 -52
- package/src/lib/csrf-protection.js +5 -2
- package/src/lib/date-utils.js +3 -127
- package/src/lib/debug-registry.js +0 -5
- package/src/lib/domain-loader.js +4 -1
- package/src/lib/email-sender.js +25 -23
- package/src/lib/error-handler.js +5 -41
- package/src/lib/error-recovery.js +10 -7
- package/src/lib/error-resilience.js +7 -5
- package/src/lib/events-engine.js +9 -6
- package/src/lib/export-sink.js +0 -5
- package/src/lib/field-iterator.js +7 -76
- package/src/lib/field-registry.js +0 -22
- package/src/lib/generic-crud-handler.js +0 -16
- package/src/lib/hook-engine.js +5 -53
- package/src/lib/hot-reload/checkpoint.js +5 -2
- package/src/lib/hot-reload/promise-container.js +7 -3
- package/src/lib/hot-reload/route-wrapper.js +4 -1
- package/src/lib/hot-reload/supervisor.js +6 -3
- package/src/lib/hyperformula-service.js +0 -5
- package/src/lib/id-helpers.js +10 -0
- package/src/lib/index.js +4 -7
- package/src/lib/log-aggregator.js +0 -1
- package/src/lib/logger.js +0 -15
- package/src/lib/metrics-collector.js +2 -46
- package/src/lib/next-compat.js +15 -1
- package/src/lib/observability-bootstrap.js +0 -5
- package/src/lib/perf-monitor.js +4 -1
- package/src/lib/perf-profiler.js +0 -5
- package/src/lib/query-string-adapter.js +5 -47
- package/src/lib/realtime-server.js +4 -26
- package/src/lib/request-tracing.js +0 -5
- package/src/lib/resource-monitor.js +4 -1
- package/src/lib/response-formatter.js +0 -38
- package/src/lib/route-helpers.js +3 -1
- package/src/lib/safe-json.js +5 -1
- package/src/lib/server-bootstrap.js +10 -9
- package/src/lib/stage-pipeline.js +2 -2
- package/src/lib/state-transport-client.js +4 -1
- package/src/lib/static-server.js +2 -2
- package/src/lib/status-helpers.js +2 -51
- package/src/lib/tracing.js +0 -5
- package/src/lib/utils.js +6 -67
- package/src/lib/validate.js +5 -88
- package/src/lib/validators.js +0 -38
- package/src/lib/with-audit-logging.js +1 -1
- package/src/lib/workflow-engine.js +10 -77
- package/src/lib/xstate-workflow-engine.js +3 -9
- package/src/plugins/index.js +5 -3
- package/src/server/server.js +26 -27
- package/src/services/collaborator-role.service.js +21 -77
- package/src/services/email-sender.js +7 -34
- package/src/services/notification-engine.js +12 -33
- package/src/services/permission.service.js +10 -73
- package/src/ui/advanced-search-renderer.js +3 -2
- package/src/ui/advanced-widgets.js +1 -1
- package/src/ui/auth-pages.js +10 -9
- package/src/ui/auth-styles.js +1 -1
- package/src/ui/client.js +1 -1
- package/src/ui/component-engine.js +1 -1
- package/src/ui/dashboard-renderer.js +5 -5
- package/src/ui/dialog-engine.js +6 -9
- package/src/ui/dialog-factory.js +2 -2
- package/src/ui/engagement-cards.js +7 -7
- package/src/ui/engagement-dialogs.js +2 -3
- package/src/ui/engagement-grid-renderer.js +20 -19
- package/src/ui/entity-renderer.js +56 -42
- package/src/ui/file-dialogs.js +25 -9
- package/src/ui/flexup-report-renderer.js +21 -18
- package/src/ui/format-helpers.js +27 -28
- package/src/ui/highlight-threading-renderer.js +9 -9
- package/src/ui/job-management-renderer.js +18 -16
- package/src/ui/layout.js +51 -25
- package/src/ui/ml-console-renderer.js +15 -16
- package/src/ui/monitoring-dashboard-client.js +9 -8
- package/src/ui/notifications-renderer.js +11 -10
- package/src/ui/page-handler-admin.js +32 -23
- package/src/ui/page-handler-helpers.js +27 -23
- package/src/ui/page-handler-reviews.js +71 -35
- package/src/ui/page-handler-rfi.js +28 -14
- package/src/ui/page-handler.js +65 -48
- package/src/ui/picker-dialogs.js +26 -19
- package/src/ui/render-helpers.js +84 -46
- package/src/ui/review-comparison-renderer.js +8 -7
- package/src/ui/review-detail-panels.js +8 -9
- package/src/ui/review-detail-renderer.js +3 -3
- package/src/ui/review-detail-script.js +13 -12
- package/src/ui/review-mwr-renderer.js +6 -5
- package/src/ui/review-renderer.js +7 -6
- package/src/ui/review-widgets.js +3 -3
- package/src/ui/rfi-detail-renderer.js +37 -20
- package/src/ui/rfi-report-renderer.js +14 -9
- package/src/ui/settings-renderer-advanced.js +21 -18
- package/src/ui/settings-renderer-advanced2.js +20 -19
- package/src/ui/settings-renderer-teams.js +4 -4
- package/src/ui/settings-renderer.js +4 -4
- package/src/ui/spacing-system.js +5 -2
- package/src/ui/standalone-login.js +2 -2
- package/src/ui/styles.css +2 -1
- package/src/ui/styles2.css +75 -5
- package/src/ui/test-page.js +4 -4
- package/src/ui/widgets.js +20 -15
- package/src/lib/audit-logger.js +0 -193
- package/src/lib/database-core.js +0 -255
- package/src/lib/database-migrations.js +0 -350
- package/src/lib/db-backup.js +0 -97
- package/src/lib/error-boundary.js +0 -134
- package/src/lib/errors.js +0 -69
- package/src/lib/health-monitor.js +0 -134
- package/src/lib/index-optimizer.js +0 -96
- package/src/lib/query-engine-write.js +0 -221
- package/src/lib/query-engine.js +0 -446
- package/src/ui/collaboration-dialogs.js +0 -31
- package/src/ui/global-tags.js +0 -144
- package/src/ui/idle-logout.js +0 -156
- package/src/ui/letter-dialogs.js +0 -37
- package/src/ui/password-reset-page.js +0 -146
- package/src/ui/perf-renderer.js +0 -71
- package/src/ui/rfi-renderer.js +0 -194
- package/src/ui/validation-ui.js +0 -147
|
@@ -1,21 +1,15 @@
|
|
|
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';
|
|
1
|
+
import { get, update, create } from './busybase-store.js';
|
|
7
2
|
import { getConfigEngineSync } from './config-generator-engine.js';
|
|
3
|
+
import { executeHook } from './hook-engine.js';
|
|
8
4
|
import { AppError } from './error-handler.js';
|
|
9
5
|
import { HTTP } from '../config/constants.js';
|
|
6
|
+
import { createLogger } from './logger.js';
|
|
7
|
+
|
|
8
|
+
const log = createLogger('[WorkflowEngine]');
|
|
10
9
|
|
|
11
10
|
const LOCKOUT_SECONDS = 300; // 5 minutes default
|
|
12
11
|
const workflowCache = new Map();
|
|
13
12
|
|
|
14
|
-
/**
|
|
15
|
-
* Get workflow definition from config
|
|
16
|
-
* @param {string} workflowName
|
|
17
|
-
* @returns {object}
|
|
18
|
-
*/
|
|
19
13
|
function getWorkflowDef(workflowName) {
|
|
20
14
|
if (workflowCache.has(workflowName)) {
|
|
21
15
|
return workflowCache.get(workflowName);
|
|
@@ -42,15 +36,6 @@ function getWorkflowDef(workflowName) {
|
|
|
42
36
|
return def;
|
|
43
37
|
}
|
|
44
38
|
|
|
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
39
|
export function validateTransition(workflowName, fromState, toState, user) {
|
|
55
40
|
const def = getWorkflowDef(workflowName);
|
|
56
41
|
const fromCfg = def.stageMap[fromState];
|
|
@@ -70,18 +55,15 @@ export function validateTransition(workflowName, fromState, toState, user) {
|
|
|
70
55
|
);
|
|
71
56
|
}
|
|
72
57
|
|
|
73
|
-
// Role requirement check
|
|
74
58
|
const requiresRole = toCfg.requires_role || [];
|
|
75
59
|
if (requiresRole.length > 0 && user && !requiresRole.includes(user.role)) {
|
|
76
60
|
throw new AppError(`Role "${user.role}" cannot enter state "${toState}"`, 'INSUFFICIENT_PERMISSIONS', HTTP.FORBIDDEN);
|
|
77
61
|
}
|
|
78
62
|
|
|
79
|
-
// Entry constraint (e.g., partner_only)
|
|
80
63
|
if (toCfg.entry === 'partner_only' && user?.role !== 'partner') {
|
|
81
64
|
throw new AppError(`Only partners can enter "${toState}"`, 'ENTRY_CONSTRAINT', HTTP.FORBIDDEN);
|
|
82
65
|
}
|
|
83
66
|
|
|
84
|
-
// Readonly check
|
|
85
67
|
if (toCfg.readonly) {
|
|
86
68
|
throw new AppError(`State "${toState}" is read-only`, 'STATE_READONLY', HTTP.FORBIDDEN);
|
|
87
69
|
}
|
|
@@ -92,14 +74,6 @@ export function validateTransition(workflowName, fromState, toState, user) {
|
|
|
92
74
|
};
|
|
93
75
|
}
|
|
94
76
|
|
|
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
77
|
export function getAvailableTransitions(workflowName, currentState, user, record = null) {
|
|
104
78
|
const def = getWorkflowDef(workflowName);
|
|
105
79
|
const currentCfg = def.stageMap[currentState];
|
|
@@ -111,7 +85,6 @@ export function getAvailableTransitions(workflowName, currentState, user, record
|
|
|
111
85
|
|
|
112
86
|
for (const stateName of candidates) {
|
|
113
87
|
try {
|
|
114
|
-
// Lockout check based on last_transition_at
|
|
115
88
|
if (record?.last_transition_at) {
|
|
116
89
|
const elapsed = (Date.now() / 1000) - record.last_transition_at;
|
|
117
90
|
if (elapsed < LOCKOUT_SECONDS) continue;
|
|
@@ -126,18 +99,13 @@ export function getAvailableTransitions(workflowName, currentState, user, record
|
|
|
126
99
|
backward: (cfg.order || 0) < currentOrder,
|
|
127
100
|
});
|
|
128
101
|
} catch {
|
|
129
|
-
//
|
|
102
|
+
// skip
|
|
130
103
|
}
|
|
131
104
|
}
|
|
132
105
|
|
|
133
106
|
return available;
|
|
134
107
|
}
|
|
135
108
|
|
|
136
|
-
/**
|
|
137
|
-
* Get transition lockout status
|
|
138
|
-
* @param {object} record
|
|
139
|
-
* @returns {{inLockout: boolean, minutesRemaining: number, failedGates: Array}}
|
|
140
|
-
*/
|
|
141
109
|
export function getTransitionStatus(record) {
|
|
142
110
|
let inLockout = false;
|
|
143
111
|
let minutesRemaining = 0;
|
|
@@ -153,24 +121,12 @@ export function getTransitionStatus(record) {
|
|
|
153
121
|
return { inLockout, minutesRemaining, failedGates: [] };
|
|
154
122
|
}
|
|
155
123
|
|
|
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
124
|
export async function transition(entityType, entityId, workflowName, toState, user, reason = '') {
|
|
167
|
-
|
|
168
|
-
const record = get(entityType, entityId);
|
|
125
|
+
const record = await get(entityType, entityId);
|
|
169
126
|
if (!record) throw new AppError('Record not found', 'NOT_FOUND', HTTP.NOT_FOUND);
|
|
170
127
|
|
|
171
128
|
validateTransition(workflowName, record.status || record.stage, toState, user);
|
|
172
129
|
|
|
173
|
-
// Update record
|
|
174
130
|
const updates = {
|
|
175
131
|
status: toState,
|
|
176
132
|
updated_at: Math.floor(Date.now() / 1000),
|
|
@@ -181,10 +137,9 @@ export async function transition(entityType, entityId, workflowName, toState, us
|
|
|
181
137
|
}
|
|
182
138
|
|
|
183
139
|
// Dynamically import write engine to avoid circular dependency
|
|
184
|
-
const { update: updateRecord } = await import('./
|
|
185
|
-
const updated = updateRecord(entityType, entityId, updates, user);
|
|
140
|
+
const { update: updateRecord } = await import('./busybase-store.js');
|
|
141
|
+
const updated = await updateRecord(entityType, entityId, updates, user);
|
|
186
142
|
|
|
187
|
-
// Execute hooks
|
|
188
143
|
executeHook(`transition:${entityType}`, {
|
|
189
144
|
entity: entityType,
|
|
190
145
|
id: entityId,
|
|
@@ -192,26 +147,16 @@ export async function transition(entityType, entityId, workflowName, toState, us
|
|
|
192
147
|
to: toState,
|
|
193
148
|
user,
|
|
194
149
|
record: updated,
|
|
195
|
-
}).catch(err =>
|
|
150
|
+
}).catch(err => log.error('transition hook error:', { message: err?.message || String(err) }));
|
|
196
151
|
|
|
197
152
|
return updated;
|
|
198
153
|
}
|
|
199
154
|
|
|
200
|
-
/**
|
|
201
|
-
* Get state field name for workflow (usually 'status' or 'stage')
|
|
202
|
-
* @param {string} workflowName
|
|
203
|
-
* @returns {string}
|
|
204
|
-
*/
|
|
205
155
|
export function getStateField(workflowName) {
|
|
206
156
|
const def = getWorkflowDef(workflowName);
|
|
207
157
|
return def.state_field || 'status';
|
|
208
158
|
}
|
|
209
159
|
|
|
210
|
-
/**
|
|
211
|
-
* Get stage labels mapping
|
|
212
|
-
* @param {string} workflowName
|
|
213
|
-
* @returns {object}
|
|
214
|
-
*/
|
|
215
160
|
export function getStageLabels(workflowName) {
|
|
216
161
|
const def = getWorkflowDef(workflowName);
|
|
217
162
|
const labels = {};
|
|
@@ -223,12 +168,6 @@ export function getStageLabels(workflowName) {
|
|
|
223
168
|
return labels;
|
|
224
169
|
}
|
|
225
170
|
|
|
226
|
-
/**
|
|
227
|
-
* Get locked fields for a state (readonly fields)
|
|
228
|
-
* @param {string} workflowName
|
|
229
|
-
* @param {string} state
|
|
230
|
-
* @returns {string[]}
|
|
231
|
-
*/
|
|
232
171
|
export function getStateLocks(workflowName, state) {
|
|
233
172
|
const def = getWorkflowDef(workflowName);
|
|
234
173
|
const stage = def.stageMap[state];
|
|
@@ -236,12 +175,6 @@ export function getStateLocks(workflowName, state) {
|
|
|
236
175
|
return stage.locks || [];
|
|
237
176
|
}
|
|
238
177
|
|
|
239
|
-
/**
|
|
240
|
-
* Get actions available in a state
|
|
241
|
-
* @param {string} workflowName
|
|
242
|
-
* @param {string} state
|
|
243
|
-
* @returns {string[]}
|
|
244
|
-
*/
|
|
245
178
|
export function getStateActions(workflowName, state) {
|
|
246
179
|
const def = getWorkflowDef(workflowName);
|
|
247
180
|
const stage = def.stageMap[state];
|
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* XState Workflow Engine - State machine-based workflow management for Thatcher
|
|
3
|
-
* Replaces the legacy workflow engine with xstate v5 state machines and actors
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
1
|
import { createMachine, createActor, assign, fromPromise, sendParent } from 'xstate';
|
|
7
2
|
import { getConfigEngineSync } from './config-generator-engine.js';
|
|
8
3
|
import { hookEngine } from './hook-engine.js';
|
|
@@ -390,10 +385,9 @@ export function getAvailableTransitions(workflowName, currentState, user, record
|
|
|
390
385
|
}
|
|
391
386
|
|
|
392
387
|
export async function transition(entityType, entityId, workflowName, toState, user, reason = '') {
|
|
393
|
-
const { get } = await import('./
|
|
394
|
-
const { update: updateRecord } = await import('./query-engine-write.js');
|
|
388
|
+
const { get, update: updateRecord } = await import('./busybase-store.js');
|
|
395
389
|
|
|
396
|
-
const record = get(entityType, entityId);
|
|
390
|
+
const record = await get(entityType, entityId);
|
|
397
391
|
if (!record) throw new AppError('Record not found', 'NOT_FOUND', HTTP.NOT_FOUND);
|
|
398
392
|
|
|
399
393
|
validateTransition(workflowName, record.status || record.stage, toState, user);
|
|
@@ -407,7 +401,7 @@ export async function transition(entityType, entityId, workflowName, toState, us
|
|
|
407
401
|
updates.transition_reason = reason;
|
|
408
402
|
}
|
|
409
403
|
|
|
410
|
-
const updated = updateRecord(entityType, entityId, updates
|
|
404
|
+
const updated = await updateRecord(entityType, entityId, updates);
|
|
411
405
|
|
|
412
406
|
_transitionHistory.push({
|
|
413
407
|
entityType,
|
package/src/plugins/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { fileURLToPath } from 'url';
|
|
4
|
+
import { createLogger } from '../lib/logger.js';
|
|
4
5
|
|
|
5
6
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const log = createLogger('[Plugins]');
|
|
6
8
|
|
|
7
9
|
export async function loadPlugins(configEngine) {
|
|
8
10
|
const pluginDir = __dirname;
|
|
@@ -15,13 +17,13 @@ export async function loadPlugins(configEngine) {
|
|
|
15
17
|
try {
|
|
16
18
|
const mod = await import(`file://${path.join(pluginDir, file)}?t=${Date.now()}`);
|
|
17
19
|
const plugin = mod.default || mod;
|
|
18
|
-
if (!plugin.entityName) {
|
|
20
|
+
if (!plugin.entityName) { log.warn(`${file} missing entityName, skipped`); continue; }
|
|
19
21
|
configEngine.registerPlugin(plugin.entityName, plugin);
|
|
20
22
|
loaded.push(file);
|
|
21
23
|
} catch (e) {
|
|
22
|
-
|
|
24
|
+
log.error(`failed to load ${file}:`, { message: e.message });
|
|
23
25
|
}
|
|
24
26
|
}
|
|
25
|
-
if (loaded.length)
|
|
27
|
+
if (loaded.length) log.info(`loaded: ${loaded.join(', ')}`);
|
|
26
28
|
return loaded;
|
|
27
29
|
}
|
package/src/server/server.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Thatcher HTTP Server - Simplified generic CRUD router
|
|
3
|
-
* Provides out-of-the-box API for all entities
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
1
|
import http from 'http';
|
|
7
2
|
import path from 'path';
|
|
3
|
+
import fs from 'fs';
|
|
8
4
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
5
|
+
import { createLogger } from '../lib/logger.js';
|
|
9
6
|
import * as thatcherLib from '../index.js';
|
|
10
7
|
|
|
8
|
+
const log = createLogger('[Server]');
|
|
9
|
+
const pageLog = createLogger('[Page]');
|
|
10
|
+
const staticLog = createLogger('[Static]');
|
|
11
|
+
const apiLog = createLogger('[API]');
|
|
12
|
+
|
|
11
13
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
14
|
|
|
13
15
|
export function createServer(options) {
|
|
@@ -57,7 +59,7 @@ export function createServer(options) {
|
|
|
57
59
|
const { loadPlugins } = await load(path.join(__dirname, '../plugins/index.js'));
|
|
58
60
|
await loadPlugins(configEngine);
|
|
59
61
|
systemInitialized = true;
|
|
60
|
-
|
|
62
|
+
log.info('System ready');
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
@@ -113,7 +115,7 @@ export function createServer(options) {
|
|
|
113
115
|
return;
|
|
114
116
|
}
|
|
115
117
|
} catch (e) {
|
|
116
|
-
|
|
118
|
+
pageLog.error(e.message);
|
|
117
119
|
}
|
|
118
120
|
}
|
|
119
121
|
|
|
@@ -122,7 +124,7 @@ export function createServer(options) {
|
|
|
122
124
|
res.end(JSON.stringify({ error: 'Not found' }));
|
|
123
125
|
|
|
124
126
|
} catch (err) {
|
|
125
|
-
|
|
127
|
+
log.error(err.message);
|
|
126
128
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
127
129
|
res.end(JSON.stringify({ error: err.message }));
|
|
128
130
|
} finally {
|
|
@@ -133,9 +135,6 @@ export function createServer(options) {
|
|
|
133
135
|
return server;
|
|
134
136
|
}
|
|
135
137
|
|
|
136
|
-
/**
|
|
137
|
-
* Serve static file if exists
|
|
138
|
-
*/
|
|
139
138
|
async function serveStaticFile(pathname, req, res, load) {
|
|
140
139
|
if (pathname === '/' || pathname === '/index.html') {
|
|
141
140
|
// Could serve SPA
|
|
@@ -160,13 +159,14 @@ async function serveStaticFile(pathname, req, res, load) {
|
|
|
160
159
|
res.end(content);
|
|
161
160
|
return true;
|
|
162
161
|
}
|
|
163
|
-
} catch {
|
|
162
|
+
} catch (err) {
|
|
163
|
+
// A missing file is a normal 404; anything else (permission denied, file
|
|
164
|
+
// locked) is a real problem worth surfacing rather than silently swallowing.
|
|
165
|
+
if (err.code !== 'ENOENT') staticLog.error(`${filePath} ${err.code}`, { message: err.message });
|
|
166
|
+
}
|
|
164
167
|
return false;
|
|
165
168
|
}
|
|
166
169
|
|
|
167
|
-
/**
|
|
168
|
-
* File exists check
|
|
169
|
-
*/
|
|
170
170
|
function fileExists(p) {
|
|
171
171
|
try {
|
|
172
172
|
return fs.existsSync(p);
|
|
@@ -175,9 +175,6 @@ function fileExists(p) {
|
|
|
175
175
|
}
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
-
/**
|
|
179
|
-
* Send response appropriately
|
|
180
|
-
*/
|
|
181
178
|
async function sendResponse(res, response) {
|
|
182
179
|
const headerObj = {};
|
|
183
180
|
if (response.headers) {
|
|
@@ -208,9 +205,6 @@ async function sendResponse(res, response) {
|
|
|
208
205
|
res.end(bodyOut);
|
|
209
206
|
}
|
|
210
207
|
|
|
211
|
-
/**
|
|
212
|
-
* Generic CRUD handler
|
|
213
|
-
*/
|
|
214
208
|
async function handleGenericCrud(req, res, entity, id, action, thatcher, configEngineArg) {
|
|
215
209
|
// Simple auth: get from cookie or header
|
|
216
210
|
let user = null;
|
|
@@ -299,26 +293,31 @@ async function handleGenericCrud(req, res, entity, id, action, thatcher, configE
|
|
|
299
293
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
300
294
|
res.end(JSON.stringify(result));
|
|
301
295
|
} catch (err) {
|
|
302
|
-
|
|
296
|
+
apiLog.error(err.message);
|
|
303
297
|
res.writeHead(500);
|
|
304
298
|
res.end(JSON.stringify({ error: err.message }));
|
|
305
299
|
}
|
|
306
300
|
}
|
|
307
301
|
|
|
308
|
-
/**
|
|
309
|
-
* Read request body
|
|
310
|
-
*/
|
|
311
302
|
async function readBody(req) {
|
|
312
303
|
return new Promise((resolve, reject) => {
|
|
313
304
|
let data = '';
|
|
314
|
-
|
|
305
|
+
let size = 0;
|
|
306
|
+
const MAX_SIZE = 10 * 1024 * 1024; // 10MB cap: an unbounded body is an OOM/DoS vector
|
|
307
|
+
const timeout = setTimeout(() => { req.destroy(); reject(new Error('Request timeout')); }, 30000); // slowloris guard
|
|
308
|
+
req.on('data', chunk => {
|
|
309
|
+
size += chunk.length;
|
|
310
|
+
if (size > MAX_SIZE) { clearTimeout(timeout); req.destroy(); reject(new Error('Request body too large')); return; }
|
|
311
|
+
data += chunk;
|
|
312
|
+
});
|
|
315
313
|
req.on('end', () => {
|
|
314
|
+
clearTimeout(timeout);
|
|
316
315
|
try {
|
|
317
316
|
resolve(JSON.parse(data));
|
|
318
317
|
} catch {
|
|
319
318
|
resolve(data);
|
|
320
319
|
}
|
|
321
320
|
});
|
|
322
|
-
req.on('error', reject);
|
|
321
|
+
req.on('error', (err) => { clearTimeout(timeout); reject(err); });
|
|
323
322
|
});
|
|
324
323
|
}
|
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
* Collaborator Role Service - Role-based access control for review collaborators
|
|
3
|
-
* Extracted from moonlanding/src/services/collaborator-role.service.js
|
|
4
|
-
*/
|
|
1
|
+
// Extracted from moonlanding/src/services/collaborator-role.service.js
|
|
5
2
|
|
|
6
|
-
import { list, get, update, create } from '../lib/
|
|
3
|
+
import { list, get, update, create } from '../lib/busybase-store.js';
|
|
7
4
|
import { AppError } from '../lib/error-handler.js';
|
|
8
5
|
import { HTTP } from '../config/constants.js';
|
|
9
6
|
|
|
@@ -23,22 +20,17 @@ const COLLABORATOR_ROLE_PERMISSIONS = {
|
|
|
23
20
|
],
|
|
24
21
|
};
|
|
25
22
|
|
|
26
|
-
|
|
27
|
-
* Get active collaborator role
|
|
28
|
-
* @param {string} collaboratorId
|
|
29
|
-
* @returns {object|null}
|
|
30
|
-
*/
|
|
31
|
-
export function getCollaboratorRole(collaboratorId) {
|
|
23
|
+
export async function getCollaboratorRole(collaboratorId) {
|
|
32
24
|
if (!collaboratorId) return null;
|
|
33
|
-
const collaborator = get('collaborator', collaboratorId);
|
|
25
|
+
const collaborator = await get('collaborator', collaboratorId);
|
|
34
26
|
if (!collaborator) return null;
|
|
35
27
|
|
|
36
28
|
if (collaborator.primary_role_id) {
|
|
37
|
-
const role = get('collaborator_role', collaborator.primary_role_id);
|
|
29
|
+
const role = await get('collaborator_role', collaborator.primary_role_id);
|
|
38
30
|
if (role && role.is_active) return role;
|
|
39
31
|
}
|
|
40
32
|
|
|
41
|
-
const roles = list('collaborator_role')
|
|
33
|
+
const roles = (await list('collaborator_role'))
|
|
42
34
|
.filter(r => r.collaborator_id === collaboratorId && r.is_active);
|
|
43
35
|
|
|
44
36
|
if (roles.length === 0) return null;
|
|
@@ -47,7 +39,7 @@ export function getCollaboratorRole(collaboratorId) {
|
|
|
47
39
|
const activeRole = roles[0];
|
|
48
40
|
|
|
49
41
|
if (collaborator.primary_role_id !== activeRole.id) {
|
|
50
|
-
update('collaborator', collaboratorId, {
|
|
42
|
+
await update('collaborator', collaboratorId, {
|
|
51
43
|
primary_role_id: activeRole.id,
|
|
52
44
|
is_manager: activeRole.role_type === 'manager'
|
|
53
45
|
});
|
|
@@ -55,78 +47,42 @@ export function getCollaboratorRole(collaboratorId) {
|
|
|
55
47
|
return activeRole;
|
|
56
48
|
}
|
|
57
49
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
* @param {string} collaboratorId
|
|
61
|
-
* @param {string} permission
|
|
62
|
-
* @returns {boolean}
|
|
63
|
-
*/
|
|
64
|
-
export function hasCollaboratorPermission(collaboratorId, permission) {
|
|
65
|
-
const role = getCollaboratorRole(collaboratorId);
|
|
50
|
+
export async function hasCollaboratorPermission(collaboratorId, permission) {
|
|
51
|
+
const role = await getCollaboratorRole(collaboratorId);
|
|
66
52
|
if (!role) return false;
|
|
67
53
|
const perms = COLLABORATOR_ROLE_PERMISSIONS[role.role_type];
|
|
68
54
|
return perms ? perms.includes(permission) : false;
|
|
69
55
|
}
|
|
70
56
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
* @param {string} collaboratorId
|
|
74
|
-
* @param {string} action
|
|
75
|
-
* @param {object} [record]
|
|
76
|
-
* @returns {boolean}
|
|
77
|
-
*/
|
|
78
|
-
export function checkCollaboratorAccess(collaboratorId, action, record = null) {
|
|
79
|
-
const role = getCollaboratorRole(collaboratorId);
|
|
57
|
+
export async function checkCollaboratorAccess(collaboratorId, action, record = null) {
|
|
58
|
+
const role = await getCollaboratorRole(collaboratorId);
|
|
80
59
|
if (!role) return false;
|
|
81
60
|
const perms = COLLABORATOR_ROLE_PERMISSIONS[role.role_type];
|
|
82
61
|
if (!perms) return false;
|
|
83
62
|
if (perms.includes(action)) return true;
|
|
84
63
|
if (action === 'delete_highlights' && perms.includes('delete_own_highlights') && record) {
|
|
85
|
-
const collaborator = get('collaborator', collaboratorId);
|
|
64
|
+
const collaborator = await get('collaborator', collaboratorId);
|
|
86
65
|
return record.created_by === collaborator?.user_id;
|
|
87
66
|
}
|
|
88
67
|
return false;
|
|
89
68
|
}
|
|
90
69
|
|
|
91
|
-
|
|
92
|
-
* Get role history for collaborator
|
|
93
|
-
* @param {string} collaboratorId
|
|
94
|
-
* @returns {Array}
|
|
95
|
-
*/
|
|
96
|
-
export function getCollaboratorRoleHistory(collaboratorId) {
|
|
70
|
+
export async function getCollaboratorRoleHistory(collaboratorId) {
|
|
97
71
|
if (!collaboratorId) return [];
|
|
98
|
-
return list('collaborator_role')
|
|
72
|
+
return (await list('collaborator_role'))
|
|
99
73
|
.filter(r => r.collaborator_id === collaboratorId)
|
|
100
74
|
.sort((a, b) => b.assigned_at - a.assigned_at);
|
|
101
75
|
}
|
|
102
76
|
|
|
103
|
-
/**
|
|
104
|
-
* Get permissions for a role type
|
|
105
|
-
* @param {string} roleType
|
|
106
|
-
* @returns {Array<string>}
|
|
107
|
-
*/
|
|
108
77
|
export function getCollaboratorRolePermissions(roleType) {
|
|
109
78
|
return COLLABORATOR_ROLE_PERMISSIONS[roleType] || [];
|
|
110
79
|
}
|
|
111
80
|
|
|
112
|
-
/**
|
|
113
|
-
* Check if user role can assign target role
|
|
114
|
-
* @param {string} userRole
|
|
115
|
-
* @param {string} targetRoleType
|
|
116
|
-
* @returns {boolean}
|
|
117
|
-
*/
|
|
118
81
|
export function canAssignRole(userRole, targetRoleType) {
|
|
119
82
|
return ['partner', 'manager'].includes(userRole);
|
|
120
83
|
}
|
|
121
84
|
|
|
122
|
-
|
|
123
|
-
* Add a collaborator to a review
|
|
124
|
-
* @param {string} reviewId
|
|
125
|
-
* @param {string} email
|
|
126
|
-
* @param {object} options
|
|
127
|
-
* @returns {object}
|
|
128
|
-
*/
|
|
129
|
-
export function addCollaborator(reviewId, email, options = {}) {
|
|
85
|
+
export async function addCollaborator(reviewId, email, options = {}) {
|
|
130
86
|
const { expiresAt, createdBy = 'system', reason = '' } = options;
|
|
131
87
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
132
88
|
const isPermanent = !expiresAt;
|
|
@@ -137,7 +93,7 @@ export function addCollaborator(reviewId, email, options = {}) {
|
|
|
137
93
|
if (expiresAt > maxAllowed) throw new Error('Expiry date cannot exceed 30 days from now');
|
|
138
94
|
}
|
|
139
95
|
|
|
140
|
-
return create('collaborator', {
|
|
96
|
+
return await create('collaborator', {
|
|
141
97
|
review_id: reviewId,
|
|
142
98
|
email,
|
|
143
99
|
expires_at: expiresAt || null,
|
|
@@ -149,13 +105,8 @@ export function addCollaborator(reviewId, email, options = {}) {
|
|
|
149
105
|
}, { id: createdBy, role: 'partner' });
|
|
150
106
|
}
|
|
151
107
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
* @param {string} reviewId
|
|
155
|
-
* @returns {Array}
|
|
156
|
-
*/
|
|
157
|
-
export function getReviewCollaborators(reviewId) {
|
|
158
|
-
const collaborators = list('collaborator', { review_id: reviewId });
|
|
108
|
+
export async function getReviewCollaborators(reviewId) {
|
|
109
|
+
const collaborators = await list('collaborator', { review_id: reviewId });
|
|
159
110
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
160
111
|
|
|
161
112
|
return collaborators.map(c => ({
|
|
@@ -171,18 +122,11 @@ export function getReviewCollaborators(reviewId) {
|
|
|
171
122
|
}));
|
|
172
123
|
}
|
|
173
124
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
* @param {string} collaboratorId
|
|
177
|
-
* @param {string} [reason='manual_revoke']
|
|
178
|
-
* @param {string} [revokedBy='system']
|
|
179
|
-
* @returns {boolean}
|
|
180
|
-
*/
|
|
181
|
-
export function revokeCollaborator(collaboratorId, reason = 'manual_revoke', revokedBy = 'system') {
|
|
182
|
-
const collaborator = get('collaborator', collaboratorId);
|
|
125
|
+
export async function revokeCollaborator(collaboratorId, reason = 'manual_revoke', revokedBy = 'system') {
|
|
126
|
+
const collaborator = await get('collaborator', collaboratorId);
|
|
183
127
|
if (!collaborator) throw new Error('Collaborator not found');
|
|
184
128
|
|
|
185
|
-
update('collaborator', collaboratorId, {
|
|
129
|
+
await update('collaborator', collaboratorId, {
|
|
186
130
|
revoked_at: Math.floor(Date.now() / 1000),
|
|
187
131
|
revoked_by: revokedBy,
|
|
188
132
|
revocation_reason: reason,
|
|
@@ -1,29 +1,23 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Email Sender - SMTP email delivery with templates
|
|
3
|
-
* Uses nodemailer
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
1
|
import nodemailer from 'nodemailer';
|
|
7
2
|
import { buildConfig } from '../config/env.js';
|
|
3
|
+
import { createLogger } from '../lib/logger.js';
|
|
4
|
+
|
|
5
|
+
const log = createLogger('[Email]');
|
|
8
6
|
|
|
9
7
|
let transporter = null;
|
|
10
8
|
|
|
11
|
-
/**
|
|
12
|
-
* Initialize email transporter
|
|
13
|
-
* @param {object} [config]
|
|
14
|
-
*/
|
|
15
9
|
export function initEmail(config = null) {
|
|
16
10
|
const cfg = config || buildConfig();
|
|
17
11
|
|
|
18
12
|
if (!cfg.email.smtp.user || !cfg.email.smtp.password) {
|
|
19
|
-
|
|
13
|
+
log.warn('SMTP credentials not configured');
|
|
20
14
|
return null;
|
|
21
15
|
}
|
|
22
16
|
|
|
23
17
|
transporter = nodemailer.createTransport({
|
|
24
18
|
host: cfg.email.smtp.host,
|
|
25
19
|
port: cfg.email.smtp.port,
|
|
26
|
-
secure: cfg.email.smtp.port === 465,
|
|
20
|
+
secure: cfg.email.smtp.port === 465,
|
|
27
21
|
auth: {
|
|
28
22
|
user: cfg.email.smtp.user,
|
|
29
23
|
pass: cfg.email.smtp.password,
|
|
@@ -33,20 +27,11 @@ export function initEmail(config = null) {
|
|
|
33
27
|
return transporter;
|
|
34
28
|
}
|
|
35
29
|
|
|
36
|
-
/**
|
|
37
|
-
* Get initialized transporter
|
|
38
|
-
* @returns {object}
|
|
39
|
-
*/
|
|
40
30
|
export function getTransporter() {
|
|
41
31
|
if (!transporter) initEmail();
|
|
42
32
|
return transporter;
|
|
43
33
|
}
|
|
44
34
|
|
|
45
|
-
/**
|
|
46
|
-
* Send email
|
|
47
|
-
* @param {object} options - { to, subject, text, html, attachments }
|
|
48
|
-
* @returns {Promise<object>}
|
|
49
|
-
*/
|
|
50
35
|
export async function sendEmail(options) {
|
|
51
36
|
const transporter = getTransporter();
|
|
52
37
|
if (!transporter) {
|
|
@@ -60,19 +45,11 @@ export async function sendEmail(options) {
|
|
|
60
45
|
...options,
|
|
61
46
|
});
|
|
62
47
|
|
|
63
|
-
|
|
48
|
+
log.info('sent:', { messageId: info.messageId });
|
|
64
49
|
return info;
|
|
65
50
|
}
|
|
66
51
|
|
|
67
|
-
/**
|
|
68
|
-
* Send templated email
|
|
69
|
-
* @param {string} templateName
|
|
70
|
-
* @param {string} to
|
|
71
|
-
* @param {object} context
|
|
72
|
-
* @returns {Promise<object>}
|
|
73
|
-
*/
|
|
74
52
|
export async function sendTemplatedEmail(templateName, to, context = {}) {
|
|
75
|
-
// Templates could be loaded from config or files
|
|
76
53
|
const templates = getTemplates();
|
|
77
54
|
const template = templates[templateName];
|
|
78
55
|
|
|
@@ -87,15 +64,11 @@ export async function sendTemplatedEmail(templateName, to, context = {}) {
|
|
|
87
64
|
return sendEmail({ to, subject, text, html });
|
|
88
65
|
}
|
|
89
66
|
|
|
90
|
-
/**
|
|
91
|
-
* Simple templates (could be loaded from config)
|
|
92
|
-
* @returns {object}
|
|
93
|
-
*/
|
|
94
67
|
function getTemplates() {
|
|
95
68
|
return {
|
|
96
69
|
notification: {
|
|
97
70
|
subject: 'Notification from {{appName}}',
|
|
98
|
-
text: '{{message}}\n\n
|
|
71
|
+
text: '{{message}}\n\n- {{appName}}',
|
|
99
72
|
},
|
|
100
73
|
invitation: {
|
|
101
74
|
subject: 'You\'ve been invited to {{appName}}',
|