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.
Files changed (221) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +398 -0
  3. package/package.json +73 -0
  4. package/src/adapters/google-auth.js +148 -0
  5. package/src/adapters/google-drive.js +209 -0
  6. package/src/app/api/[entity]/[[...path]]/route.js +33 -0
  7. package/src/app/api/audit/dashboard/route.js +77 -0
  8. package/src/app/api/audit/logs/route.js +46 -0
  9. package/src/app/api/audit/permissions/[id]/route.js +37 -0
  10. package/src/app/api/audit/permissions/route.js +93 -0
  11. package/src/app/api/audit/permissions/stats/route.js +29 -0
  12. package/src/app/api/audit/route.js +79 -0
  13. package/src/app/api/audit/stats/route.js +18 -0
  14. package/src/app/api/auth/google/callback/route.js +94 -0
  15. package/src/app/api/auth/google/route.js +58 -0
  16. package/src/app/api/auth/login/route.js +121 -0
  17. package/src/app/api/auth/logout/route.js +52 -0
  18. package/src/app/api/auth/me/route.js +16 -0
  19. package/src/app/api/auth/mwr-bridge/route.js +77 -0
  20. package/src/app/api/auth/password-reset/route.js +65 -0
  21. package/src/app/api/cron/trigger/route.js +58 -0
  22. package/src/app/api/csrf-token/route.js +8 -0
  23. package/src/app/api/debug/config/route.js +22 -0
  24. package/src/app/api/debug/hooks/route.js +16 -0
  25. package/src/app/api/debug/plugins/route.js +20 -0
  26. package/src/app/api/debug/sqlite/route.js +21 -0
  27. package/src/app/api/debug/sync/route.js +29 -0
  28. package/src/app/api/debug/workflow/route.js +20 -0
  29. package/src/app/api/domains/[domain]/route.js +26 -0
  30. package/src/app/api/domains/route.js +21 -0
  31. package/src/app/api/email/allocate/batch/route.js +106 -0
  32. package/src/app/api/email/allocate/route.js +141 -0
  33. package/src/app/api/email/receive/route.js +158 -0
  34. package/src/app/api/email/route.js +3 -0
  35. package/src/app/api/email/send/route.js +77 -0
  36. package/src/app/api/email/unallocated/route.js +47 -0
  37. package/src/app/api/files/[id]/route.js +38 -0
  38. package/src/app/api/health/route.js +95 -0
  39. package/src/app/api/metrics/route.js +73 -0
  40. package/src/app/api/monitoring/dashboard/route.js +18 -0
  41. package/src/cli.js +243 -0
  42. package/src/config/config-loader.js +112 -0
  43. package/src/config/constants.js +127 -0
  44. package/src/config/env.js +164 -0
  45. package/src/config/spec-helpers.js +232 -0
  46. package/src/engine.server.js +212 -0
  47. package/src/index.js +368 -0
  48. package/src/lib/accessibility.js +162 -0
  49. package/src/lib/action-factory.js +34 -0
  50. package/src/lib/action-utils.js +21 -0
  51. package/src/lib/alert-manager.js +189 -0
  52. package/src/lib/api-error-wrapper.js +125 -0
  53. package/src/lib/api-helpers.js +53 -0
  54. package/src/lib/api.js +82 -0
  55. package/src/lib/audit-logger-enhanced.js +117 -0
  56. package/src/lib/audit-logger.js +193 -0
  57. package/src/lib/auth-middleware.js +102 -0
  58. package/src/lib/auth-route-helpers.js +83 -0
  59. package/src/lib/business-rules-engine.js +86 -0
  60. package/src/lib/compression.js +44 -0
  61. package/src/lib/config-field-helpers.js +91 -0
  62. package/src/lib/config-generator-engine.js +445 -0
  63. package/src/lib/config-helpers.js +120 -0
  64. package/src/lib/connection-guard.js +79 -0
  65. package/src/lib/crud-action-helpers.js +34 -0
  66. package/src/lib/crud-factory.js +83 -0
  67. package/src/lib/crud-handlers.js +244 -0
  68. package/src/lib/csrf-protection.js +63 -0
  69. package/src/lib/database-core.js +258 -0
  70. package/src/lib/database-migrations.js +96 -0
  71. package/src/lib/date-utils.js +159 -0
  72. package/src/lib/db-backup.js +97 -0
  73. package/src/lib/db-monitor.js +127 -0
  74. package/src/lib/domain-loader.js +82 -0
  75. package/src/lib/email-sender.js +100 -0
  76. package/src/lib/error-boundary.js +134 -0
  77. package/src/lib/error-handler.js +84 -0
  78. package/src/lib/error-recovery.js +190 -0
  79. package/src/lib/error-resilience.js +130 -0
  80. package/src/lib/errors.js +69 -0
  81. package/src/lib/events-engine.js +182 -0
  82. package/src/lib/field-iterator.js +50 -0
  83. package/src/lib/field-registry.js +68 -0
  84. package/src/lib/field-types.js +154 -0
  85. package/src/lib/generic-crud-handler.js +32 -0
  86. package/src/lib/health-monitor.js +134 -0
  87. package/src/lib/hook-engine.js +169 -0
  88. package/src/lib/hot-reload/cache-invalidator.js +115 -0
  89. package/src/lib/hot-reload/checkpoint.js +95 -0
  90. package/src/lib/hot-reload/debug-exposure.js +67 -0
  91. package/src/lib/hot-reload/directory-watcher.js +96 -0
  92. package/src/lib/hot-reload/index.js +50 -0
  93. package/src/lib/hot-reload/mutex.js +75 -0
  94. package/src/lib/hot-reload/promise-container.js +66 -0
  95. package/src/lib/hot-reload/route-wrapper.js +46 -0
  96. package/src/lib/hot-reload/safe-error.js +51 -0
  97. package/src/lib/hot-reload/supervisor.js +161 -0
  98. package/src/lib/hot-reload/timeout-wrapper.js +52 -0
  99. package/src/lib/http-methods-factory.js +25 -0
  100. package/src/lib/index-optimizer.js +96 -0
  101. package/src/lib/index.js +35 -0
  102. package/src/lib/list-data-transform.js +39 -0
  103. package/src/lib/log-aggregator.js +116 -0
  104. package/src/lib/logger.js +55 -0
  105. package/src/lib/metrics-collector.js +102 -0
  106. package/src/lib/minifier.js +19 -0
  107. package/src/lib/monitoring-init.js +67 -0
  108. package/src/lib/next-compat.js +80 -0
  109. package/src/lib/next-polyfills.js +135 -0
  110. package/src/lib/perf-monitor.js +91 -0
  111. package/src/lib/progress-components.js +181 -0
  112. package/src/lib/query-cache.js +126 -0
  113. package/src/lib/query-engine-write.js +221 -0
  114. package/src/lib/query-engine.js +399 -0
  115. package/src/lib/query-perf.js +117 -0
  116. package/src/lib/query-string-adapter.js +75 -0
  117. package/src/lib/realtime-server.js +67 -0
  118. package/src/lib/render-cache.js +61 -0
  119. package/src/lib/request-tracker.js +43 -0
  120. package/src/lib/resource-hints.js +29 -0
  121. package/src/lib/resource-monitor.js +117 -0
  122. package/src/lib/response-formatter.js +80 -0
  123. package/src/lib/route-helpers.js +33 -0
  124. package/src/lib/route-resolver.js +142 -0
  125. package/src/lib/safe-json.js +8 -0
  126. package/src/lib/server-bootstrap.js +71 -0
  127. package/src/lib/stage-pipeline.js +153 -0
  128. package/src/lib/state-protocol.js +171 -0
  129. package/src/lib/state-transport-client.js +169 -0
  130. package/src/lib/state-transport-reconnect.js +121 -0
  131. package/src/lib/state-transport-server.js +181 -0
  132. package/src/lib/static-server.js +97 -0
  133. package/src/lib/status-helpers.js +98 -0
  134. package/src/lib/universal-handler.js +7 -0
  135. package/src/lib/utils.js +93 -0
  136. package/src/lib/validate.js +197 -0
  137. package/src/lib/validation/business-validators.js +61 -0
  138. package/src/lib/validation/csrf.js +51 -0
  139. package/src/lib/validation/file-validators.js +34 -0
  140. package/src/lib/validation/format-validators.js +106 -0
  141. package/src/lib/validation/index.js +19 -0
  142. package/src/lib/validation/rate-limit.js +31 -0
  143. package/src/lib/validation/security-validators.js +78 -0
  144. package/src/lib/validation-middleware.js +133 -0
  145. package/src/lib/validators.js +105 -0
  146. package/src/lib/with-audit-logging.js +63 -0
  147. package/src/lib/with-error-handler.js +31 -0
  148. package/src/lib/workflow-engine.js +250 -0
  149. package/src/server/server.js +305 -0
  150. package/src/services/collaborator-role.service.js +205 -0
  151. package/src/services/email-sender.js +105 -0
  152. package/src/services/notification-engine.js +110 -0
  153. package/src/services/permission.service.js +181 -0
  154. package/src/ui/advanced-search-renderer.js +42 -0
  155. package/src/ui/advanced-widgets.js +47 -0
  156. package/src/ui/auth-pages.js +114 -0
  157. package/src/ui/auth-styles.js +53 -0
  158. package/src/ui/client.js +99 -0
  159. package/src/ui/collaboration-dialogs.js +31 -0
  160. package/src/ui/common-handlers.js +156 -0
  161. package/src/ui/component-engine.js +103 -0
  162. package/src/ui/dashboard-renderer.js +150 -0
  163. package/src/ui/dialog-engine.js +147 -0
  164. package/src/ui/dialog-factory.js +38 -0
  165. package/src/ui/engagement-cards.js +76 -0
  166. package/src/ui/engagement-dialogs.js +100 -0
  167. package/src/ui/engagement-grid-renderer.js +109 -0
  168. package/src/ui/entity-renderer.js +176 -0
  169. package/src/ui/event-delegation.js +108 -0
  170. package/src/ui/fetch-json.js +24 -0
  171. package/src/ui/file-dialogs.js +81 -0
  172. package/src/ui/flexup-report-renderer.js +173 -0
  173. package/src/ui/format-helpers.js +102 -0
  174. package/src/ui/global-tags.js +144 -0
  175. package/src/ui/highlight-threading-renderer.js +176 -0
  176. package/src/ui/idle-logout.js +156 -0
  177. package/src/ui/job-management-renderer.js +61 -0
  178. package/src/ui/layout.js +219 -0
  179. package/src/ui/letter-dialogs.js +37 -0
  180. package/src/ui/ml-console-renderer.js +108 -0
  181. package/src/ui/monitoring-dashboard-client.js +136 -0
  182. package/src/ui/monitoring-dashboard.js +134 -0
  183. package/src/ui/notifications-renderer.js +51 -0
  184. package/src/ui/page-handler-admin.js +121 -0
  185. package/src/ui/page-handler-helpers.js +111 -0
  186. package/src/ui/page-handler-reviews.js +165 -0
  187. package/src/ui/page-handler-rfi.js +42 -0
  188. package/src/ui/page-handler.js +210 -0
  189. package/src/ui/password-reset-page.js +146 -0
  190. package/src/ui/perf-helpers.js +96 -0
  191. package/src/ui/perf-renderer.js +71 -0
  192. package/src/ui/permissions-ui.js +163 -0
  193. package/src/ui/picker-dialogs.js +100 -0
  194. package/src/ui/render-helpers.js +124 -0
  195. package/src/ui/renderer.js +35 -0
  196. package/src/ui/review-comparison-renderer.js +58 -0
  197. package/src/ui/review-detail-panels.js +71 -0
  198. package/src/ui/review-detail-renderer.js +170 -0
  199. package/src/ui/review-detail-script.js +95 -0
  200. package/src/ui/review-mwr-renderer.js +113 -0
  201. package/src/ui/review-renderer.js +202 -0
  202. package/src/ui/review-widgets.js +88 -0
  203. package/src/ui/review-zone-nav.js +12 -0
  204. package/src/ui/rfi-detail-renderer.js +191 -0
  205. package/src/ui/rfi-renderer.js +194 -0
  206. package/src/ui/rfi-report-renderer.js +56 -0
  207. package/src/ui/rippleui.css +1 -0
  208. package/src/ui/settings-renderer-advanced.js +195 -0
  209. package/src/ui/settings-renderer-advanced2.js +158 -0
  210. package/src/ui/settings-renderer-teams.js +112 -0
  211. package/src/ui/settings-renderer.js +166 -0
  212. package/src/ui/spacing-system.js +155 -0
  213. package/src/ui/standalone-login.js +109 -0
  214. package/src/ui/styles.css +2530 -0
  215. package/src/ui/styles2.css +1602 -0
  216. package/src/ui/test-page.js +23 -0
  217. package/src/ui/validation-rules.js +73 -0
  218. package/src/ui/validation-ui.js +147 -0
  219. package/src/ui/virtual-scroll.js +107 -0
  220. package/src/ui/webjsx.js +61 -0
  221. package/src/ui/widgets.js +152 -0
@@ -0,0 +1,193 @@
1
+ import { getDatabase, genId, now } from '@/lib/database-core';
2
+ import { Mutex } from '@/lib/hot-reload/mutex';
3
+
4
+ const db = getDatabase();
5
+ const auditMutex = new Mutex('audit-logger');
6
+
7
+ export const ACTIVITY_TYPES = {
8
+ RFI_QUESTION_VIEW: 'rfi_question_view',
9
+ RFI_QUESTION_RESPOND: 'rfi_question_respond',
10
+ RFI_QUESTION_COMMENT: 'rfi_question_comment',
11
+ RFI_QUESTION_CREATE: 'rfi_question_create',
12
+ RFI_QUESTION_UPDATE: 'rfi_question_update',
13
+ RFI_QUESTION_DELETE: 'rfi_question_delete',
14
+ RFI_QUESTION_ASSIGN: 'rfi_question_assign',
15
+ RFI_QUESTION_DEADLINE: 'rfi_question_deadline',
16
+ HIGHLIGHT_CREATE: 'highlight_create',
17
+ HIGHLIGHT_RESOLVE: 'highlight_resolve',
18
+ HIGHLIGHT_REOPEN: 'highlight_reopen',
19
+ HIGHLIGHT_DELETE: 'highlight_delete',
20
+ REVIEW_CREATE: 'review_create',
21
+ REVIEW_UPDATE: 'review_update',
22
+ REVIEW_ARCHIVE: 'review_archive',
23
+ COLLABORATOR_ADD: 'collaborator_add',
24
+ COLLABORATOR_REMOVE: 'collaborator_remove',
25
+ PERMISSION_CHANGE: 'permission_change',
26
+ ROLE_CHANGE: 'role_change',
27
+ };
28
+
29
+ const parseJson = (val) => val ? JSON.parse(val) : null;
30
+
31
+ const parseRow = (row) => row ? {
32
+ ...row,
33
+ old_permissions: parseJson(row.old_permissions),
34
+ new_permissions: parseJson(row.new_permissions),
35
+ metadata: parseJson(row.metadata),
36
+ before_state: parseJson(row.before_state),
37
+ after_state: parseJson(row.after_state),
38
+ } : null;
39
+
40
+ export const logAction = (entityType, entityId, action, userId, beforeState, afterState) => {
41
+ const id = genId();
42
+ const timestamp = now();
43
+ db.prepare(`
44
+ INSERT INTO audit_logs (id, entity_type, entity_id, action, user_id, before_state, after_state, created_at)
45
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
46
+ `).run(id, entityType, entityId, action, userId || null,
47
+ beforeState ? JSON.stringify(beforeState) : null,
48
+ afterState ? JSON.stringify(afterState) : null,
49
+ timestamp
50
+ );
51
+ return { id, timestamp };
52
+ };
53
+
54
+ export const getAuditHistory = (filters = {}, page = 1, pageSize = 50) => {
55
+ const wc = [], params = [];
56
+ if (filters.entityType) { wc.push('entity_type = ?'); params.push(filters.entityType); }
57
+ if (filters.entityId) { wc.push('entity_id = ?'); params.push(filters.entityId); }
58
+ if (filters.userId) { wc.push('user_id = ?'); params.push(filters.userId); }
59
+ if (filters.action) { wc.push('action = ?'); params.push(filters.action); }
60
+ if (filters.fromDate) { wc.push('created_at >= ?'); params.push(filters.fromDate); }
61
+ if (filters.toDate) { wc.push('created_at <= ?'); params.push(filters.toDate); }
62
+ const where = wc.length ? 'WHERE ' + wc.join(' AND ') : '';
63
+ const { count: total } = db.prepare(`SELECT COUNT(*) as count FROM audit_logs ${where}`).get(...params);
64
+ const offset = (page - 1) * pageSize;
65
+ const items = db.prepare(`SELECT * FROM audit_logs ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`).all(...params, pageSize, offset);
66
+ return {
67
+ items: items.map(i => ({
68
+ id: i.id, entityType: i.entity_type, entityId: i.entity_id, action: i.action,
69
+ userId: i.user_id, beforeState: parseJson(i.before_state), afterState: parseJson(i.after_state), createdAt: i.created_at,
70
+ })),
71
+ pagination: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) },
72
+ };
73
+ };
74
+
75
+ export const getEntityAuditTrail = (entityType, entityId) => {
76
+ return db.prepare(`SELECT * FROM audit_logs WHERE entity_type = ? AND entity_id = ? ORDER BY created_at DESC`).all(entityType, entityId)
77
+ .map(i => ({ id: i.id, action: i.action, userId: i.user_id, beforeState: parseJson(i.before_state), afterState: parseJson(i.after_state), createdAt: i.created_at }));
78
+ };
79
+
80
+ const statsSql = (col) => `SELECT ${col}, COUNT(*) as count FROM audit_logs WHERE created_at >= ? AND created_at <= ? GROUP BY ${col} ORDER BY count DESC`;
81
+ export const getActionStats = (fromDate, toDate) => db.prepare(statsSql('action')).all(fromDate, toDate);
82
+ export const getUserStats = (fromDate, toDate) => db.prepare(statsSql('user_id')).all(fromDate, toDate);
83
+
84
+ export const logPermissionChange = ({
85
+ userId, entityType, entityId, action, oldPermissions = null, newPermissions = null,
86
+ reason = null, reasonCode = 'other', affectedUserId = null, ipAddress = null, sessionId = null, metadata = null,
87
+ }) => {
88
+ if (!userId || !entityType || !entityId || !action) throw new Error('Missing required audit fields');
89
+ const auditId = genId();
90
+ const timestamp = now();
91
+ db.prepare(`
92
+ INSERT INTO permission_audit (id, user_id, entity_type, entity_id, action, old_permissions, new_permissions,
93
+ reason, reason_code, timestamp, ip_address, session_id, affected_user_id, metadata, created_at, updated_at, created_by, updated_by)
94
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
95
+ `).run(auditId, userId, entityType, entityId, action,
96
+ oldPermissions ? JSON.stringify(oldPermissions) : null, newPermissions ? JSON.stringify(newPermissions) : null,
97
+ reason, reasonCode, timestamp, ipAddress, sessionId, affectedUserId,
98
+ metadata ? JSON.stringify(metadata) : null, timestamp, timestamp, userId, userId
99
+ );
100
+ return auditId;
101
+ };
102
+
103
+ export const auditPermissionChange = async ({ user, entityType, entityId, action, oldPermissions = null,
104
+ newPermissions = null, affectedUserId = null, reason = null, reasonCode = 'admin_action', metadata = null }) => {
105
+ return auditMutex.runExclusive(() => {
106
+ try {
107
+ logPermissionChange({ userId: user.id, entityType, entityId, action, oldPermissions, newPermissions,
108
+ reason: reason || `Permission ${action}`, reasonCode, affectedUserId, metadata });
109
+ } catch (error) {
110
+ console.error('[Audit] Failed to log permission change:', error);
111
+ }
112
+ });
113
+ };
114
+
115
+ export const auditRoleChange = async ({ user, targetUserId, oldRole, newRole, reason = null, metadata = null }) => {
116
+ await auditPermissionChange({ user, entityType: 'user', entityId: targetUserId, action: 'role_change',
117
+ oldPermissions: { role: oldRole }, newPermissions: { role: newRole },
118
+ affectedUserId: targetUserId, reason: reason || `Role changed from ${oldRole} to ${newRole}`,
119
+ reasonCode: 'role_change', metadata: { ...metadata, old_role: oldRole, new_role: newRole } });
120
+ };
121
+
122
+ export const auditCollaboratorAdded = async ({ user, reviewId, collaboratorId, collaboratorUserId, permissions = null, reason = null, metadata = null }) => {
123
+ await auditPermissionChange({ user, entityType: 'review', entityId: reviewId, action: 'grant',
124
+ newPermissions: permissions || { access: 'collaborator' }, affectedUserId: collaboratorUserId,
125
+ reason: reason || 'Collaborator added to review', reasonCode: 'collaborator_added',
126
+ metadata: { ...metadata, collaborator_id: collaboratorId, review_id: reviewId } });
127
+ };
128
+
129
+ export const auditCollaboratorRemoved = async ({ user, reviewId, collaboratorId, collaboratorUserId, permissions = null, reason = null, metadata = null }) => {
130
+ await auditPermissionChange({ user, entityType: 'review', entityId: reviewId, action: 'revoke',
131
+ oldPermissions: permissions || { access: 'collaborator' }, affectedUserId: collaboratorUserId,
132
+ reason: reason || 'Collaborator removed from review', reasonCode: 'collaborator_removed',
133
+ metadata: { ...metadata, collaborator_id: collaboratorId, review_id: reviewId } });
134
+ };
135
+
136
+ export const auditLifecycleTransition = async ({ user, entityType, entityId, fromStage, toStage, metadata = null }) => {
137
+ await auditPermissionChange({ user, entityType, entityId, action: 'modify',
138
+ oldPermissions: { stage: fromStage }, newPermissions: { stage: toStage },
139
+ reason: `Lifecycle transition: ${fromStage} -> ${toStage}`, reasonCode: 'lifecycle_transition',
140
+ metadata: { ...metadata, from_stage: fromStage, to_stage: toStage } });
141
+ };
142
+
143
+ export const auditPermissionModify = async ({ user, entityType, entityId, oldPermissions, newPermissions,
144
+ affectedUserId = null, reason = null, reasonCode = 'admin_action', metadata = null }) => {
145
+ await auditPermissionChange({ user, entityType, entityId, action: 'modify',
146
+ oldPermissions, newPermissions, affectedUserId, reason: reason || 'Permissions modified', reasonCode, metadata });
147
+ };
148
+
149
+ export const getPermissionAuditTrail = ({ entityType, entityId, userId, affectedUserId, limit = 100, offset = 0 }) => {
150
+ let sql = 'SELECT * FROM permission_audit WHERE 1=1';
151
+ const params = [];
152
+ if (entityType) { sql += ' AND entity_type = ?'; params.push(entityType); }
153
+ if (entityId) { sql += ' AND entity_id = ?'; params.push(entityId); }
154
+ if (userId) { sql += ' AND user_id = ?'; params.push(userId); }
155
+ if (affectedUserId) { sql += ' AND affected_user_id = ?'; params.push(affectedUserId); }
156
+ sql += ' ORDER BY timestamp DESC LIMIT ? OFFSET ?';
157
+ return db.prepare(sql).all(...params, limit, offset).map(parseRow);
158
+ };
159
+
160
+ export const getPermissionAuditById = (auditId) => parseRow(db.prepare('SELECT * FROM permission_audit WHERE id = ?').get(auditId));
161
+ export const getPermissionAuditStats = () => db.prepare(`SELECT COUNT(*) as total_audits, COUNT(DISTINCT user_id) as unique_users, COUNT(DISTINCT entity_type) as entity_types, MIN(timestamp) as earliest_change, MAX(timestamp) as latest_change FROM permission_audit`).get();
162
+ export const getPermissionAuditBreakdown = (field) => db.prepare(`SELECT ${field}, COUNT(*) as count FROM permission_audit GROUP BY ${field} ORDER BY count DESC`).all();
163
+
164
+ export const searchPermissionAudit = (searchTerm, limit = 100) => {
165
+ const pattern = `%${searchTerm}%`;
166
+ return db.prepare(`SELECT * FROM permission_audit WHERE reason LIKE ? OR entity_type LIKE ? OR entity_id LIKE ? ORDER BY timestamp DESC LIMIT ?`)
167
+ .all(pattern, pattern, pattern, limit).map(parseRow);
168
+ };
169
+
170
+ export const getPermissionAuditByDateRange = (startDate, endDate, limit = 100) =>
171
+ db.prepare(`SELECT * FROM permission_audit WHERE timestamp >= ? AND timestamp <= ? ORDER BY timestamp DESC LIMIT ?`).all(startDate, endDate, limit).map(parseRow);
172
+
173
+ export const exportPermissionAuditCSV = async (filters = {}) => {
174
+ const trail = getPermissionAuditTrail({ ...filters, limit: 10000 });
175
+ const h = 'Timestamp,Changed By,Entity Type,Entity ID,Action,Reason Code,Reason,Affected User,IP Address';
176
+ const rows = trail.map(a => [new Date(a.timestamp * 1000).toISOString(), a.user_id, a.entity_type,
177
+ a.entity_id, a.action, a.reason_code, a.reason || '', a.affected_user_id || '', a.ip_address || '']
178
+ .map(c => `"${c}"`).join(','));
179
+ return [h, ...rows].join('\n');
180
+ };
181
+
182
+ export const logQuestionActivity = (questionId, action, userId, details = null) => {
183
+ return logAction('rfi_question', questionId, action, userId, null, details);
184
+ };
185
+
186
+ export const getPermissionDiff = (oldPerms, newPerms) => {
187
+ const diff = { added: [], removed: [], unchanged: [] };
188
+ const oldSet = new Set(Array.isArray(oldPerms) ? oldPerms : []);
189
+ const newSet = new Set(Array.isArray(newPerms) ? newPerms : []);
190
+ for (const p of newSet) { if (!oldSet.has(p)) diff.added.push(p); else diff.unchanged.push(p); }
191
+ for (const p of oldSet) { if (!newSet.has(p)) diff.removed.push(p); }
192
+ return diff;
193
+ };
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Auth Middleware - Authentication and authorization utilities
3
+ * Adapted from moonlanding/src/lib/auth-middleware.js
4
+ */
5
+
6
+ import { getUser, lucia } from '../engine.server.js'; // Will need to adapt
7
+ import { getSpec } from './spec-helpers.js';
8
+ import { can } from '../services/permission.service.js';
9
+ import { UnauthorizedError, PermissionError, NotFoundError } from './error-handler.js';
10
+
11
+ const actionMap = {
12
+ list: 'list',
13
+ get: 'view',
14
+ view: 'view',
15
+ create: 'create',
16
+ update: 'edit',
17
+ edit: 'edit',
18
+ delete: 'delete',
19
+ };
20
+
21
+ /**
22
+ * Get session token from request headers
23
+ * @param {object} req - HTTP request
24
+ * @returns {string|null}
25
+ */
26
+ export function getSessionToken(req) {
27
+ const cookieHeader = req?.headers?.cookie || '';
28
+ if (!cookieHeader) return null;
29
+ const cookieName = lucia?.sessionCookieName || 'thatcher_session';
30
+ const match = cookieHeader.split(';').find(c => c.trim().startsWith(cookieName + '='));
31
+ if (!match) return null;
32
+ const value = match.split('=')[1];
33
+ return value ? decodeURIComponent(value.trim()) : null;
34
+ }
35
+
36
+ /**
37
+ * Require authentication (throws if not authenticated)
38
+ * @returns {Promise<object>} User object
39
+ */
40
+ export async function requireAuth() {
41
+ const user = await getUser();
42
+ if (!user) throw UnauthorizedError('Authentication required');
43
+ return user;
44
+ }
45
+
46
+ /**
47
+ * Require permission for action on spec
48
+ * @param {object} user
49
+ * @param {object} spec
50
+ * @param {string} action
51
+ */
52
+ export async function requirePermission(user, spec, action) {
53
+ const mapped = actionMap[action] || action;
54
+ if (!(await can(user, spec, mapped))) {
55
+ throw PermissionError(`Cannot ${action} ${spec.name}`);
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Create authenticated handler wrapper
61
+ * @param {Function} handler
62
+ * @param {string} action
63
+ * @returns {Function}
64
+ */
65
+ export function withAuth(handler, action = 'view') {
66
+ return async (request, context) => {
67
+ const user = await requireAuth();
68
+ const entity = context.params?.entity || context.entity;
69
+ const spec = entity ? getSpec(entity) : null;
70
+ if (spec) await requirePermission(user, spec, action);
71
+ return handler(request, { ...context, user, spec });
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Page auth (for HTML page rendering)
77
+ * @param {string} entityName
78
+ * @param {string} action
79
+ * @param {object} options
80
+ * @returns {Promise<{user, spec}>}
81
+ */
82
+ export async function withPageAuth(entityName, action = 'view', options = {}) {
83
+ const user = await getUser();
84
+ if (!user) throw UnauthorizedError('Not authenticated');
85
+
86
+ let spec;
87
+ try {
88
+ spec = getSpec(entityName);
89
+ } catch {
90
+ throw NotFoundError(`Entity ${entityName} not found`);
91
+ }
92
+
93
+ if (options.notEmbedded !== false && spec.embedded) {
94
+ throw NotFoundError('Entity is embedded');
95
+ }
96
+
97
+ if (!(await can(user, spec, actionMap[action] || action))) {
98
+ throw PermissionError(`Cannot ${action} ${entityName}`);
99
+ }
100
+
101
+ return { user, spec };
102
+ }
@@ -0,0 +1,83 @@
1
+ import { NextResponse } from '@/lib/next-polyfills';
2
+ import { SESSION } from '@/config/auth-config';
3
+ import { HTTP } from '@/config/constants';
4
+
5
+ const oauthStateStore = new Map();
6
+
7
+ setInterval(() => {
8
+ const now = Date.now();
9
+ for (const [key, data] of oauthStateStore) {
10
+ if (data.expiresAt < now) {
11
+ oauthStateStore.delete(key);
12
+ }
13
+ }
14
+ }, 5 * 60 * 1000);
15
+
16
+ export function validateOAuthProvider(provider) {
17
+ if (!provider) {
18
+ return {
19
+ valid: false,
20
+ error: 'OAuth not configured',
21
+ };
22
+ }
23
+ return { valid: true };
24
+ }
25
+
26
+ export async function setOAuthCookie(name, value, options = {}) {
27
+ const timestamp = Date.now();
28
+ const key = `oauth-${timestamp}-${Math.random().toString(36).substring(7)}`;
29
+ const expiresAt = timestamp + (SESSION.cookieMaxAge * 1000);
30
+
31
+ oauthStateStore.set(key, {
32
+ value,
33
+ expiresAt,
34
+ createdAt: timestamp,
35
+ });
36
+
37
+ return key;
38
+ }
39
+
40
+ export async function getOAuthCookie(name) {
41
+ const key = name;
42
+
43
+ const data = oauthStateStore.get(key);
44
+
45
+ if (!data) {
46
+ return null;
47
+ }
48
+
49
+ if (data.expiresAt < Date.now()) {
50
+ oauthStateStore.delete(key);
51
+ return null;
52
+ }
53
+
54
+ return data.value;
55
+ }
56
+
57
+ export async function deleteOAuthCookie(name) {
58
+ const key = name;
59
+ if (key) {
60
+ oauthStateStore.delete(key);
61
+ }
62
+ }
63
+
64
+ export function buildOAuthErrorResponse(message, request) {
65
+ if (request) {
66
+ return NextResponse.redirect(new URL(`/login?error=${message}`, request.url));
67
+ }
68
+ return NextResponse.json({ error: message }, { status: HTTP.INTERNAL_ERROR });
69
+ }
70
+
71
+ export function buildOAuthSuccessRedirect(path, request) {
72
+ return NextResponse.redirect(new URL(path, request.url));
73
+ }
74
+
75
+ export function validateOAuthState(code, state, storedState, storedCodeVerifier) {
76
+ if (!code || !state) {
77
+ return { valid: false, error: 'invalid_state' };
78
+ }
79
+ if (!storedState || !storedCodeVerifier) {
80
+ return { valid: false, error: 'state_not_found' };
81
+ }
82
+ return { valid: true };
83
+ }
@@ -0,0 +1,86 @@
1
+ export class BusinessRulesEngine {
2
+ constructor(config = {}) {
3
+ this.businessRules = config.businessRules || {};
4
+ this.rules = new Map();
5
+ this.initializeDefaultRules();
6
+ }
7
+
8
+ initializeDefaultRules() {
9
+ this.registerRule('recreationAllowed', (engagement) => {
10
+ const rule = this.businessRules.recreationRules.engagement[engagement.repeat_interval];
11
+ return rule && !this.isDuplicateEngagement(engagement);
12
+ });
13
+
14
+ this.registerRule('daysOutstandingNotificationNeeded', (rfi) => {
15
+ return this.businessRules.rfiDaysOutstanding.enabled;
16
+ });
17
+
18
+ this.registerRule('clientCanViewRfi', (user, rfi) => {
19
+ if (user.type === 'client') {
20
+ const role = this.businessRules.clientRoles[user.role];
21
+ return role?.rowAccess === 'all' || rfi.assigned_to?.includes(user.id);
22
+ }
23
+ return true;
24
+ });
25
+
26
+ this.registerRule('clientCanRate', (user, engagement) => {
27
+ return user.type === 'client' && user.role === 'client_admin';
28
+ });
29
+
30
+ this.registerRule('tenderWarningNeeded', (review) => {
31
+ if (!this.businessRules.tenderDeadlines.enabled) return false;
32
+ if (review.type !== 'tender') return false;
33
+
34
+ const now = Date.now();
35
+ const deadline = review.deadline_date * 1000;
36
+ const daysUntil = (deadline - now) / (1000 * 60 * 60 * 24);
37
+
38
+ return daysUntil <= this.businessRules.tenderDeadlines.warningDays;
39
+ });
40
+ }
41
+
42
+ registerRule(name, evaluator) {
43
+ this.rules.set(name, evaluator);
44
+ return this;
45
+ }
46
+
47
+ async evaluateRule(ruleName, context) {
48
+ const rule = this.rules.get(ruleName);
49
+ if (!rule) throw new Error(`Unknown rule: ${ruleName}`);
50
+ return await rule(context);
51
+ }
52
+
53
+ getDefaultRole() {
54
+ return this.businessRules.defaultRoleAssignment;
55
+ }
56
+
57
+ getDefaultUserType() {
58
+ return this.businessRules.defaultUserType;
59
+ }
60
+
61
+ getClientRoleConfig(role) {
62
+ return this.businessRules.clientRoles[role];
63
+ }
64
+
65
+ getRecreationConfig(interval) {
66
+ return this.businessRules.recreationRules.engagement[interval];
67
+ }
68
+
69
+ isDuplicateEngagement(engagement) {
70
+ return false;
71
+ }
72
+
73
+ getDaysOutstandingConfig() {
74
+ return this.businessRules.rfiDaysOutstanding;
75
+ }
76
+
77
+ getTenderConfig() {
78
+ return this.businessRules.tenderDeadlines;
79
+ }
80
+
81
+ getGoogleDriveConfig() {
82
+ return this.businessRules.googleDriveIntegration;
83
+ }
84
+ }
85
+
86
+ export default BusinessRulesEngine;
@@ -0,0 +1,44 @@
1
+ import zlib from 'zlib';
2
+
3
+ const COMPRESSION_THRESHOLD = 1024; // Only compress files >1KB
4
+
5
+ export function compress(content, acceptEncoding = '') {
6
+ const size = Buffer.byteLength(content, 'utf-8');
7
+ if (size < COMPRESSION_THRESHOLD) return { content, encoding: null };
8
+
9
+ const ae = acceptEncoding.toLowerCase();
10
+
11
+ if (ae.includes('br')) {
12
+ const compressed = zlib.brotliCompressSync(content, {
13
+ params: {
14
+ [zlib.constants.BROTLI_PARAM_QUALITY]: 6,
15
+ [zlib.constants.BROTLI_PARAM_SIZE_HINT]: size
16
+ }
17
+ });
18
+ return { content: compressed, encoding: 'br' };
19
+ }
20
+
21
+ if (ae.includes('gzip')) {
22
+ const compressed = zlib.gzipSync(content, { level: 6 });
23
+ return { content: compressed, encoding: 'gzip' };
24
+ }
25
+
26
+ return { content, encoding: null };
27
+ }
28
+
29
+ export function getCacheHeaders(type, maxAge = 86400) {
30
+ if (type === 'static') {
31
+ return {
32
+ 'Cache-Control': `public, max-age=${maxAge}, immutable`,
33
+ 'Expires': new Date(Date.now() + maxAge * 1000).toUTCString()
34
+ };
35
+ }
36
+ if (type === 'dynamic') {
37
+ return {
38
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
39
+ 'Pragma': 'no-cache',
40
+ 'Expires': '0'
41
+ };
42
+ }
43
+ return {};
44
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Config Field Helpers - Field definition processing and validation
3
+ */
4
+
5
+ import { getSpec } from './spec-helpers.js';
6
+
7
+ /**
8
+ * Generate fields from field overrides
9
+ */
10
+ export function generateFieldsFromOverrides(overrides, baseFields) {
11
+ const result = { ...baseFields };
12
+ if (!overrides) return result;
13
+
14
+ for (const [key, def] of Object.entries(overrides)) {
15
+ if (def === null || def === undefined) {
16
+ delete result[key];
17
+ } else {
18
+ result[key] = { ...result[key], ...def };
19
+ }
20
+ }
21
+
22
+ return result;
23
+ }
24
+
25
+ /**
26
+ * Generate default field definitions for implicit fields
27
+ */
28
+ export function generateDefaultFields(entityDef) {
29
+ const defaults = {};
30
+
31
+ // Add common fields
32
+ if (!entityDef.fields) return defaults;
33
+
34
+ for (const [key, field] of Object.entries(entityDef.fields)) {
35
+ if (field.type === 'ref') {
36
+ defaults[`${key}_display`] = {
37
+ type: 'text',
38
+ label: field.label ? `${field.label} Name` : key,
39
+ computed: true,
40
+ hidden: true,
41
+ };
42
+ }
43
+ }
44
+
45
+ return defaults;
46
+ }
47
+
48
+ /**
49
+ * Build enum options from various sources (array, ref to status enum, etc.)
50
+ */
51
+ export function buildEnumOptions(fieldDef, entityName, configEngine) {
52
+ if (Array.isArray(fieldDef.options)) {
53
+ return fieldDef.options.map(o => typeof o === 'object' ? o.value : o);
54
+ }
55
+
56
+ if (typeof fieldDef.options === 'string') {
57
+ try {
58
+ const spec = getSpec(entityName, configEngine);
59
+ const list = spec.options?.[fieldDef.options];
60
+ if (list) return list.map(o => typeof o === 'object' ? o.value : o);
61
+ } catch {
62
+ // fall through
63
+ }
64
+ }
65
+
66
+ if (typeof fieldDef.options === 'string' && fieldDef.options.startsWith('status:')) {
67
+ const statusName = fieldDef.options.replace('status:', '');
68
+ try {
69
+ const statuses = configEngine.getStatuses(statusName);
70
+ return Object.keys(statuses);
71
+ } catch {
72
+ // fall through
73
+ }
74
+ }
75
+
76
+ return [];
77
+ }
78
+
79
+ /**
80
+ * Ensure all fields have labels
81
+ */
82
+ export function ensureFieldLabels(fields) {
83
+ const result = {};
84
+ for (const [key, field] of Object.entries(fields)) {
85
+ result[key] = {
86
+ ...field,
87
+ label: field.label || key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, ' '),
88
+ };
89
+ }
90
+ return result;
91
+ }