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.
Files changed (174) hide show
  1. package/README.md +4 -16
  2. package/package.json +12 -18
  3. package/src/adapters/google-auth.js +4 -43
  4. package/src/adapters/google-drive.js +12 -67
  5. package/src/app/api/audit/dashboard/route.js +14 -11
  6. package/src/app/api/audit/logs/route.js +2 -2
  7. package/src/app/api/audit/permissions/[id]/route.js +6 -3
  8. package/src/app/api/audit/permissions/route.js +10 -7
  9. package/src/app/api/audit/permissions/stats/route.js +8 -5
  10. package/src/app/api/audit/route.js +4 -4
  11. package/src/app/api/audit/stats/route.js +1 -1
  12. package/src/app/api/auth/google/callback/route.js +5 -2
  13. package/src/app/api/auth/google/route.js +4 -1
  14. package/src/app/api/auth/login/route.js +5 -2
  15. package/src/app/api/auth/mwr-bridge/route.js +11 -21
  16. package/src/app/api/auth/password-reset/route.js +15 -14
  17. package/src/app/api/cron/trigger/route.js +4 -1
  18. package/src/app/api/debug/[[...path]]/route.js +6 -5
  19. package/src/app/api/debug/sqlite/route.js +7 -12
  20. package/src/app/api/debug/sync/route.js +5 -5
  21. package/src/app/api/domains/route.js +4 -1
  22. package/src/app/api/email/allocate/batch/route.js +19 -29
  23. package/src/app/api/email/allocate/route.js +16 -17
  24. package/src/app/api/email/receive/route.js +31 -42
  25. package/src/app/api/email/send/route.js +28 -16
  26. package/src/app/api/email/unallocated/route.js +10 -13
  27. package/src/app/api/files/[id]/route.js +4 -1
  28. package/src/app/api/formula/[[...path]]/route.js +0 -4
  29. package/src/app/api/health/route.js +10 -9
  30. package/src/app/api/metrics/route.js +1 -1
  31. package/src/cli.js +4 -4
  32. package/src/config/config-loader.js +1 -21
  33. package/src/config/constants.js +0 -35
  34. package/src/config/env.js +0 -30
  35. package/src/config/spec-helpers.js +6 -95
  36. package/src/engine.js +23 -0
  37. package/src/engine.server.js +8 -78
  38. package/src/index.js +42 -67
  39. package/src/lib/alert-manager.js +7 -3
  40. package/src/lib/api-helpers.js +4 -3
  41. package/src/lib/api.js +10 -10
  42. package/src/lib/audit-logger-enhanced.js +77 -83
  43. package/src/lib/auth-middleware.js +1 -32
  44. package/src/lib/auth-route-helpers.js +5 -0
  45. package/src/lib/busybase-adapter.js +0 -5
  46. package/src/lib/busybase-audit-reads.js +142 -0
  47. package/src/lib/busybase-audit.js +41 -0
  48. package/src/lib/busybase-lucia-adapter.js +72 -0
  49. package/src/lib/busybase-store.js +338 -0
  50. package/src/lib/config-field-helpers.js +0 -17
  51. package/src/lib/config-generator-engine.js +19 -36
  52. package/src/lib/config-helpers.js +0 -21
  53. package/src/lib/crud-action-helpers.js +2 -2
  54. package/src/lib/crud-factory.js +1 -16
  55. package/src/lib/crud-handlers.js +17 -52
  56. package/src/lib/csrf-protection.js +5 -2
  57. package/src/lib/date-utils.js +3 -127
  58. package/src/lib/debug-registry.js +0 -5
  59. package/src/lib/domain-loader.js +4 -1
  60. package/src/lib/email-sender.js +25 -23
  61. package/src/lib/error-handler.js +5 -41
  62. package/src/lib/error-recovery.js +10 -7
  63. package/src/lib/error-resilience.js +7 -5
  64. package/src/lib/events-engine.js +9 -6
  65. package/src/lib/export-sink.js +0 -5
  66. package/src/lib/field-iterator.js +7 -76
  67. package/src/lib/field-registry.js +0 -22
  68. package/src/lib/generic-crud-handler.js +0 -16
  69. package/src/lib/hook-engine.js +5 -53
  70. package/src/lib/hot-reload/checkpoint.js +5 -2
  71. package/src/lib/hot-reload/promise-container.js +7 -3
  72. package/src/lib/hot-reload/route-wrapper.js +4 -1
  73. package/src/lib/hot-reload/supervisor.js +6 -3
  74. package/src/lib/hyperformula-service.js +0 -5
  75. package/src/lib/id-helpers.js +10 -0
  76. package/src/lib/index.js +4 -7
  77. package/src/lib/log-aggregator.js +0 -1
  78. package/src/lib/logger.js +0 -15
  79. package/src/lib/metrics-collector.js +2 -46
  80. package/src/lib/next-compat.js +15 -1
  81. package/src/lib/observability-bootstrap.js +0 -5
  82. package/src/lib/perf-monitor.js +4 -1
  83. package/src/lib/perf-profiler.js +0 -5
  84. package/src/lib/query-string-adapter.js +5 -47
  85. package/src/lib/realtime-server.js +4 -26
  86. package/src/lib/request-tracing.js +0 -5
  87. package/src/lib/resource-monitor.js +4 -1
  88. package/src/lib/response-formatter.js +0 -38
  89. package/src/lib/route-helpers.js +3 -1
  90. package/src/lib/safe-json.js +5 -1
  91. package/src/lib/server-bootstrap.js +10 -9
  92. package/src/lib/stage-pipeline.js +2 -2
  93. package/src/lib/state-transport-client.js +4 -1
  94. package/src/lib/static-server.js +2 -2
  95. package/src/lib/status-helpers.js +2 -51
  96. package/src/lib/tracing.js +0 -5
  97. package/src/lib/utils.js +6 -67
  98. package/src/lib/validate.js +5 -88
  99. package/src/lib/validators.js +0 -38
  100. package/src/lib/with-audit-logging.js +1 -1
  101. package/src/lib/workflow-engine.js +10 -77
  102. package/src/lib/xstate-workflow-engine.js +3 -9
  103. package/src/plugins/index.js +5 -3
  104. package/src/server/server.js +26 -27
  105. package/src/services/collaborator-role.service.js +21 -77
  106. package/src/services/email-sender.js +7 -34
  107. package/src/services/notification-engine.js +12 -33
  108. package/src/services/permission.service.js +10 -73
  109. package/src/ui/advanced-search-renderer.js +3 -2
  110. package/src/ui/advanced-widgets.js +1 -1
  111. package/src/ui/auth-pages.js +10 -9
  112. package/src/ui/auth-styles.js +1 -1
  113. package/src/ui/client.js +1 -1
  114. package/src/ui/component-engine.js +1 -1
  115. package/src/ui/dashboard-renderer.js +5 -5
  116. package/src/ui/dialog-engine.js +6 -9
  117. package/src/ui/dialog-factory.js +2 -2
  118. package/src/ui/engagement-cards.js +7 -7
  119. package/src/ui/engagement-dialogs.js +2 -3
  120. package/src/ui/engagement-grid-renderer.js +20 -19
  121. package/src/ui/entity-renderer.js +56 -42
  122. package/src/ui/file-dialogs.js +25 -9
  123. package/src/ui/flexup-report-renderer.js +21 -18
  124. package/src/ui/format-helpers.js +27 -28
  125. package/src/ui/highlight-threading-renderer.js +9 -9
  126. package/src/ui/job-management-renderer.js +18 -16
  127. package/src/ui/layout.js +51 -25
  128. package/src/ui/ml-console-renderer.js +15 -16
  129. package/src/ui/monitoring-dashboard-client.js +9 -8
  130. package/src/ui/notifications-renderer.js +11 -10
  131. package/src/ui/page-handler-admin.js +32 -23
  132. package/src/ui/page-handler-helpers.js +27 -23
  133. package/src/ui/page-handler-reviews.js +71 -35
  134. package/src/ui/page-handler-rfi.js +28 -14
  135. package/src/ui/page-handler.js +65 -48
  136. package/src/ui/picker-dialogs.js +26 -19
  137. package/src/ui/render-helpers.js +84 -46
  138. package/src/ui/review-comparison-renderer.js +8 -7
  139. package/src/ui/review-detail-panels.js +8 -9
  140. package/src/ui/review-detail-renderer.js +3 -3
  141. package/src/ui/review-detail-script.js +13 -12
  142. package/src/ui/review-mwr-renderer.js +6 -5
  143. package/src/ui/review-renderer.js +7 -6
  144. package/src/ui/review-widgets.js +3 -3
  145. package/src/ui/rfi-detail-renderer.js +37 -20
  146. package/src/ui/rfi-report-renderer.js +14 -9
  147. package/src/ui/settings-renderer-advanced.js +21 -18
  148. package/src/ui/settings-renderer-advanced2.js +20 -19
  149. package/src/ui/settings-renderer-teams.js +4 -4
  150. package/src/ui/settings-renderer.js +4 -4
  151. package/src/ui/spacing-system.js +5 -2
  152. package/src/ui/standalone-login.js +2 -2
  153. package/src/ui/styles.css +2 -1
  154. package/src/ui/styles2.css +75 -5
  155. package/src/ui/test-page.js +4 -4
  156. package/src/ui/widgets.js +20 -15
  157. package/src/lib/audit-logger.js +0 -193
  158. package/src/lib/database-core.js +0 -255
  159. package/src/lib/database-migrations.js +0 -350
  160. package/src/lib/db-backup.js +0 -97
  161. package/src/lib/error-boundary.js +0 -134
  162. package/src/lib/errors.js +0 -69
  163. package/src/lib/health-monitor.js +0 -134
  164. package/src/lib/index-optimizer.js +0 -96
  165. package/src/lib/query-engine-write.js +0 -221
  166. package/src/lib/query-engine.js +0 -446
  167. package/src/ui/collaboration-dialogs.js +0 -31
  168. package/src/ui/global-tags.js +0 -144
  169. package/src/ui/idle-logout.js +0 -156
  170. package/src/ui/letter-dialogs.js +0 -37
  171. package/src/ui/password-reset-page.js +0 -146
  172. package/src/ui/perf-renderer.js +0 -71
  173. package/src/ui/rfi-renderer.js +0 -194
  174. 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
- // Skip invalid transitions
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
- // Validate transition
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('./query-engine-write.js');
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 => console.error('transition hook error:', 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('./query-engine.js');
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, user);
404
+ const updated = await updateRecord(entityType, entityId, updates);
411
405
 
412
406
  _transitionHistory.push({
413
407
  entityType,
@@ -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) { console.warn(`[plugins] ${file} missing entityName, skipped`); continue; }
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
- console.error(`[plugins] Failed to load ${file}:`, e.message);
24
+ log.error(`failed to load ${file}:`, { message: e.message });
23
25
  }
24
26
  }
25
- if (loaded.length) console.log(`[plugins] Loaded: ${loaded.join(', ')}`);
27
+ if (loaded.length) log.info(`loaded: ${loaded.join(', ')}`);
26
28
  return loaded;
27
29
  }
@@ -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
- console.log('[Server] System ready');
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
- console.error('[Page]', e.message);
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
- console.error('[Server]', err.message);
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
- console.error('[API]', err.message);
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
- req.on('data', chunk => data += chunk);
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/query-engine.js';
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
- * Check if collaborator has permission for action
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
- * Check collaborator access for specific action (with optional record)
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
- * Get collaborators for a review
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
- * Revoke collaborator access
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
- console.warn('[Email] SMTP credentials not configured');
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, // true for 465, false for other ports
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
- console.log('[Email] Sent:', info.messageId);
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— {{appName}}',
71
+ text: '{{message}}\n\n- {{appName}}',
99
72
  },
100
73
  invitation: {
101
74
  subject: 'You\'ve been invited to {{appName}}',