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,8 +1,8 @@
1
- import { getDatabase, genId, now } from '@/lib/database-core';
2
- import { Mutex } from '@/lib/hot-reload/mutex';
1
+ import { createLogger } from './logger.js';
2
+ import { genId, now } from '@/lib/id-helpers';
3
3
 
4
- const db = getDatabase();
5
- const auditMutex = new Mutex('audit-logger-enhanced');
4
+ const log = createLogger('[AuditEnhanced]');
5
+ import { list, create, remove } from '@/lib/busybase-store';
6
6
 
7
7
  export const LOG_LEVELS = { DEBUG: 'debug', INFO: 'info', WARN: 'warn', ERROR: 'error' };
8
8
  export const OPERATION_TYPES = { CREATE: 'create', UPDATE: 'update', DELETE: 'delete', READ: 'read', AUTH: 'auth', AUTHZ: 'authz' };
@@ -10,108 +10,102 @@ export const OPERATION_TYPES = { CREATE: 'create', UPDATE: 'update', DELETE: 'de
10
10
  const MAX_LOG_SIZE = 10000;
11
11
  const MAX_STACK_DEPTH = 20;
12
12
 
13
+ // Structured log writes go to the busybase `structured_logs` table. Fire-and-forget:
14
+ // logging must never break the operation it records.
13
15
  export const logStructured = ({ level = LOG_LEVELS.INFO, operation, entityType, entityId, userId, action, details = {}, error = null, performanceMs = null }) => {
14
- return auditMutex.runExclusive(() => {
15
- const id = genId();
16
- const timestamp = now();
17
- const logEntry = {
18
- id, timestamp, level, operation, entity_type: entityType, entity_id: entityId, user_id: userId, action,
19
- details: JSON.stringify(details).substring(0, MAX_LOG_SIZE),
20
- error_message: error ? String(error.message || error).substring(0, MAX_LOG_SIZE) : null,
21
- error_stack: error && error.stack ? String(error.stack).split('\n').slice(0, MAX_STACK_DEPTH).join('\n') : null,
22
- performance_ms: performanceMs,
23
- };
24
- db.prepare(`INSERT INTO structured_logs (id, timestamp, level, operation, entity_type, entity_id, user_id, action, details, error_message, error_stack, performance_ms)
25
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
26
- id, timestamp, level, operation, entityType, entityId, userId, action, logEntry.details, logEntry.error_message, logEntry.error_stack, performanceMs
27
- );
28
- return id;
29
- });
16
+ const id = genId();
17
+ const row = {
18
+ id, timestamp: now(), level, operation: operation || '', entity_type: entityType || '', entity_id: entityId || '',
19
+ user_id: userId || '', action: action || '',
20
+ details: JSON.stringify(details).substring(0, MAX_LOG_SIZE),
21
+ error_message: error ? String(error.message || error).substring(0, MAX_LOG_SIZE) : '',
22
+ error_stack: error && error.stack ? String(error.stack).split('\n').slice(0, MAX_STACK_DEPTH).join('\n') : '',
23
+ performance_ms: performanceMs ?? 0,
24
+ };
25
+ Promise.resolve(create('structured_logs', row)).catch(e => log.error('log failed:', { message: e?.message || String(e) }));
26
+ return id;
30
27
  };
31
28
 
32
- export const logCreate = (entityType, entityId, userId, afterState) => {
33
- return logStructured({ level: LOG_LEVELS.INFO, operation: OPERATION_TYPES.CREATE, entityType, entityId, userId, action: 'create', details: { after_state: afterState } });
34
- };
29
+ export const logCreate = (entityType, entityId, userId, afterState) =>
30
+ logStructured({ level: LOG_LEVELS.INFO, operation: OPERATION_TYPES.CREATE, entityType, entityId, userId, action: 'create', details: { after_state: afterState } });
35
31
 
36
- export const logUpdate = (entityType, entityId, userId, beforeState, afterState) => {
37
- return logStructured({ level: LOG_LEVELS.INFO, operation: OPERATION_TYPES.UPDATE, entityType, entityId, userId, action: 'update', details: { before_state: beforeState, after_state: afterState } });
38
- };
32
+ export const logUpdate = (entityType, entityId, userId, beforeState, afterState) =>
33
+ logStructured({ level: LOG_LEVELS.INFO, operation: OPERATION_TYPES.UPDATE, entityType, entityId, userId, action: 'update', details: { before_state: beforeState, after_state: afterState } });
39
34
 
40
- export const logDelete = (entityType, entityId, userId, beforeState) => {
41
- return logStructured({ level: LOG_LEVELS.INFO, operation: OPERATION_TYPES.DELETE, entityType, entityId, userId, action: 'delete', details: { before_state: beforeState } });
42
- };
35
+ export const logDelete = (entityType, entityId, userId, beforeState) =>
36
+ logStructured({ level: LOG_LEVELS.INFO, operation: OPERATION_TYPES.DELETE, entityType, entityId, userId, action: 'delete', details: { before_state: beforeState } });
43
37
 
44
- export const logAuthSuccess = (userId, method, metadata = {}) => {
45
- return logStructured({ level: LOG_LEVELS.INFO, operation: OPERATION_TYPES.AUTH, entityType: 'auth', entityId: userId, userId, action: 'login_success', details: { method, ...metadata } });
46
- };
38
+ export const logAuthSuccess = (userId, method, metadata = {}) =>
39
+ logStructured({ level: LOG_LEVELS.INFO, operation: OPERATION_TYPES.AUTH, entityType: 'auth', entityId: userId, userId, action: 'login_success', details: { method, ...metadata } });
47
40
 
48
- export const logAuthFailure = (email, reason, metadata = {}) => {
49
- return logStructured({ level: LOG_LEVELS.WARN, operation: OPERATION_TYPES.AUTH, entityType: 'auth', entityId: email, userId: null, action: 'login_failure', details: { reason, ...metadata } });
50
- };
41
+ export const logAuthFailure = (email, reason, metadata = {}) =>
42
+ logStructured({ level: LOG_LEVELS.WARN, operation: OPERATION_TYPES.AUTH, entityType: 'auth', entityId: email, userId: null, action: 'login_failure', details: { reason, ...metadata } });
51
43
 
52
- export const logAuthzFailure = (userId, entityType, entityId, requiredPermission, metadata = {}) => {
53
- return logStructured({ level: LOG_LEVELS.WARN, operation: OPERATION_TYPES.AUTHZ, entityType, entityId, userId, action: 'access_denied', details: { required_permission: requiredPermission, ...metadata } });
54
- };
44
+ export const logAuthzFailure = (userId, entityType, entityId, requiredPermission, metadata = {}) =>
45
+ logStructured({ level: LOG_LEVELS.WARN, operation: OPERATION_TYPES.AUTHZ, entityType, entityId, userId, action: 'access_denied', details: { required_permission: requiredPermission, ...metadata } });
55
46
 
56
47
  export const logPerformance = (operation, entityType, durationMs, userId = null, metadata = {}) => {
57
48
  const level = durationMs > 1000 ? LOG_LEVELS.WARN : LOG_LEVELS.DEBUG;
58
49
  return logStructured({ level, operation: 'performance', entityType, entityId: null, userId, action: operation, performanceMs: durationMs, details: metadata });
59
50
  };
60
51
 
61
- export const logError = (error, context = {}) => {
62
- return logStructured({ level: LOG_LEVELS.ERROR, operation: 'error', entityType: context.entityType || 'system', entityId: context.entityId || null, userId: context.userId || null, action: context.action || 'error', error, details: context });
52
+ export const logError = (error, context = {}) =>
53
+ logStructured({ level: LOG_LEVELS.ERROR, operation: 'error', entityType: context.entityType || 'system', entityId: context.entityId || null, userId: context.userId || null, action: context.action || 'error', error, details: context });
54
+
55
+ const t = (r) => r.timestamp || 0;
56
+
57
+ export const searchLogs = async (filters = {}, page = 1, pageSize = 100) => {
58
+ const where = {};
59
+ if (filters.level) where.level = filters.level;
60
+ if (filters.operation) where.operation = filters.operation;
61
+ if (filters.entityType) where.entity_type = filters.entityType;
62
+ if (filters.entityId) where.entity_id = filters.entityId;
63
+ if (filters.userId) where.user_id = filters.userId;
64
+ if (filters.action) where.action = filters.action;
65
+ let rows = await list('structured_logs', where);
66
+ if (filters.fromDate) rows = rows.filter(r => t(r) >= filters.fromDate);
67
+ if (filters.toDate) rows = rows.filter(r => t(r) <= filters.toDate);
68
+ if (filters.searchText) {
69
+ const q = String(filters.searchText).toLowerCase();
70
+ rows = rows.filter(r => [r.details, r.error_message, r.action].some(v => String(v ?? '').toLowerCase().includes(q)));
71
+ }
72
+ rows.sort((a, b) => t(b) - t(a));
73
+ const total = rows.length;
74
+ const items = rows.slice((page - 1) * pageSize, page * pageSize)
75
+ .map(i => ({ ...i, details: i.details ? (() => { try { return JSON.parse(i.details); } catch { return null; } })() : null }));
76
+ return { items, pagination: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) } };
63
77
  };
64
78
 
65
- export const searchLogs = (filters = {}, page = 1, pageSize = 100) => {
66
- const wc = [], params = [];
67
- if (filters.level) { wc.push('level = ?'); params.push(filters.level); }
68
- if (filters.operation) { wc.push('operation = ?'); params.push(filters.operation); }
69
- if (filters.entityType) { wc.push('entity_type = ?'); params.push(filters.entityType); }
70
- if (filters.entityId) { wc.push('entity_id = ?'); params.push(filters.entityId); }
71
- if (filters.userId) { wc.push('user_id = ?'); params.push(filters.userId); }
72
- if (filters.action) { wc.push('action = ?'); params.push(filters.action); }
73
- if (filters.fromDate) { wc.push('timestamp >= ?'); params.push(filters.fromDate); }
74
- if (filters.toDate) { wc.push('timestamp <= ?'); params.push(filters.toDate); }
75
- if (filters.searchText) { wc.push('(details LIKE ? OR error_message LIKE ? OR action LIKE ?)'); const escaped = filters.searchText.replace(/[%_]/g, c => '\\' + c); const pat = `%${escaped}%`; params.push(pat, pat, pat); }
76
- const where = wc.length ? 'WHERE ' + wc.join(' AND ') : '';
77
- const { count: total } = db.prepare(`SELECT COUNT(*) as count FROM structured_logs ${where}`).get(...params);
78
- const offset = (page - 1) * pageSize;
79
- const items = db.prepare(`SELECT * FROM structured_logs ${where} ORDER BY timestamp DESC LIMIT ? OFFSET ?`).all(...params, pageSize, offset);
80
- return { items: items.map(i => ({ ...i, details: i.details ? JSON.parse(i.details) : null })), pagination: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) } };
79
+ const groupCount = (rows, field) => {
80
+ const c = {};
81
+ for (const r of rows) { const k = r[field]; c[k] = (c[k] || 0) + 1; }
82
+ return Object.entries(c).map(([k, count]) => ({ [field]: k, count })).sort((a, b) => b.count - a.count);
81
83
  };
82
84
 
83
- export const getLogStats = (fromDate, toDate) => {
84
- const byLevel = db.prepare('SELECT level, COUNT(*) as count FROM structured_logs WHERE timestamp >= ? AND timestamp <= ? GROUP BY level').all(fromDate, toDate);
85
- const byOperation = db.prepare('SELECT operation, COUNT(*) as count FROM structured_logs WHERE timestamp >= ? AND timestamp <= ? GROUP BY operation ORDER BY count DESC').all(fromDate, toDate);
86
- const byEntity = db.prepare('SELECT entity_type, COUNT(*) as count FROM structured_logs WHERE timestamp >= ? AND timestamp <= ? GROUP BY entity_type ORDER BY count DESC LIMIT 20').all(fromDate, toDate);
87
- const errorRate = db.prepare('SELECT COUNT(*) as total, SUM(CASE WHEN level = ? THEN 1 ELSE 0 END) as errors FROM structured_logs WHERE timestamp >= ? AND timestamp <= ?').get(LOG_LEVELS.ERROR, fromDate, toDate);
88
- const avgPerf = db.prepare('SELECT AVG(performance_ms) as avg, MAX(performance_ms) as max FROM structured_logs WHERE timestamp >= ? AND timestamp <= ? AND performance_ms IS NOT NULL').get(fromDate, toDate);
89
- return { byLevel, byOperation, byEntity, errorRate: errorRate.total > 0 ? errorRate.errors / errorRate.total : 0, avgPerformanceMs: avgPerf.avg, maxPerformanceMs: avgPerf.max };
85
+ export const getLogStats = async (fromDate, toDate) => {
86
+ const rows = (await list('structured_logs', {})).filter(r => t(r) >= fromDate && t(r) <= toDate);
87
+ const errors = rows.filter(r => r.level === LOG_LEVELS.ERROR).length;
88
+ const perf = rows.map(r => r.performance_ms).filter(v => typeof v === 'number' && v > 0);
89
+ return {
90
+ byLevel: groupCount(rows, 'level'),
91
+ byOperation: groupCount(rows, 'operation'),
92
+ byEntity: groupCount(rows, 'entity_type').slice(0, 20),
93
+ errorRate: rows.length > 0 ? errors / rows.length : 0,
94
+ avgPerformanceMs: perf.length ? perf.reduce((a, b) => a + b, 0) / perf.length : null,
95
+ maxPerformanceMs: perf.length ? Math.max(...perf) : null,
96
+ };
90
97
  };
91
98
 
92
- export const rotateLogsOlderThan = (daysOld = 90) => {
93
- const cutoffTimestamp = now() - (daysOld * 24 * 60 * 60);
99
+ export const rotateLogsOlderThan = async (daysOld = 90) => {
100
+ const cutoff = now() - (daysOld * 24 * 60 * 60);
94
101
  const archiveId = genId();
95
- const archived = db.prepare('SELECT * FROM structured_logs WHERE timestamp < ?').all(cutoffTimestamp);
102
+ const archived = (await list('structured_logs', {})).filter(r => t(r) < cutoff);
96
103
  if (archived.length > 0) {
97
- db.prepare('INSERT INTO archived_logs (archive_id, archived_at, log_data) VALUES (?, ?, ?)').run(archiveId, now(), JSON.stringify(archived));
98
- db.prepare('DELETE FROM structured_logs WHERE timestamp < ?').run(cutoffTimestamp);
104
+ await create('archived_logs', { archive_id: archiveId, archived_at: now(), log_data: JSON.stringify(archived) });
105
+ for (const r of archived) await remove('structured_logs', r.id);
99
106
  }
100
107
  return { archived: archived.length, archiveId };
101
108
  };
102
109
 
103
- export const ensureTables = () => {
104
- db.exec(`CREATE TABLE IF NOT EXISTS structured_logs (
105
- id TEXT PRIMARY KEY, timestamp INTEGER NOT NULL, level TEXT NOT NULL, operation TEXT, entity_type TEXT,
106
- entity_id TEXT, user_id TEXT, action TEXT, details TEXT, error_message TEXT, error_stack TEXT, performance_ms REAL
107
- )`);
108
- db.exec(`CREATE INDEX IF NOT EXISTS idx_structured_logs_timestamp ON structured_logs(timestamp)`);
109
- db.exec(`CREATE INDEX IF NOT EXISTS idx_structured_logs_level ON structured_logs(level)`);
110
- db.exec(`CREATE INDEX IF NOT EXISTS idx_structured_logs_entity ON structured_logs(entity_type, entity_id)`);
111
- db.exec(`CREATE INDEX IF NOT EXISTS idx_structured_logs_user ON structured_logs(user_id)`);
112
- db.exec(`CREATE TABLE IF NOT EXISTS archived_logs (
113
- archive_id TEXT PRIMARY KEY, archived_at INTEGER NOT NULL, log_data TEXT NOT NULL
114
- )`);
115
- };
116
-
117
- ensureTables();
110
+ // busybase is schemaless tables are created lazily on first insert.
111
+ export const ensureTables = () => {};
@@ -1,7 +1,4 @@
1
- /**
2
- * Auth Middleware - Authentication and authorization utilities
3
- * Adapted from moonlanding/src/lib/auth-middleware.js
4
- */
1
+ // Adapted from moonlanding/src/lib/auth-middleware.js
5
2
 
6
3
  import { getUser, getLucia } from '../engine.server.js';
7
4
  import { getSpec } from '../config/spec-helpers.js';
@@ -18,11 +15,6 @@ const actionMap = {
18
15
  delete: 'delete',
19
16
  };
20
17
 
21
- /**
22
- * Get session token from request headers
23
- * @param {object} req - HTTP request
24
- * @returns {string|null}
25
- */
26
18
  export function getSessionToken(req) {
27
19
  const cookieHeader = req?.headers?.cookie || '';
28
20
  if (!cookieHeader) return null;
@@ -35,22 +27,12 @@ export function getSessionToken(req) {
35
27
  return value ? decodeURIComponent(value.trim()) : null;
36
28
  }
37
29
 
38
- /**
39
- * Require authentication (throws if not authenticated)
40
- * @returns {Promise<object>} User object
41
- */
42
30
  export async function requireAuth() {
43
31
  const user = await getUser();
44
32
  if (!user) throw UnauthorizedError('Authentication required');
45
33
  return user;
46
34
  }
47
35
 
48
- /**
49
- * Require permission for action on spec
50
- * @param {object} user
51
- * @param {object} spec
52
- * @param {string} action
53
- */
54
36
  export async function requirePermission(user, spec, action) {
55
37
  const mapped = actionMap[action] || action;
56
38
  if (!(await can(user, spec, mapped))) {
@@ -58,12 +40,6 @@ export async function requirePermission(user, spec, action) {
58
40
  }
59
41
  }
60
42
 
61
- /**
62
- * Create authenticated handler wrapper
63
- * @param {Function} handler
64
- * @param {string} action
65
- * @returns {Function}
66
- */
67
43
  export function withAuth(handler, action = 'view') {
68
44
  return async (request, context) => {
69
45
  const user = await requireAuth();
@@ -74,13 +50,6 @@ export function withAuth(handler, action = 'view') {
74
50
  };
75
51
  }
76
52
 
77
- /**
78
- * Page auth (for HTML page rendering)
79
- * @param {string} entityName
80
- * @param {string} action
81
- * @param {object} options
82
- * @returns {Promise<{user, spec}>}
83
- */
84
53
  export async function withPageAuth(entityName, action = 'view', options = {}) {
85
54
  const user = await getUser();
86
55
  if (!user) throw UnauthorizedError('Not authenticated');
@@ -79,5 +79,10 @@ export function validateOAuthState(code, state, storedState, storedCodeVerifier)
79
79
  if (!storedState || !storedCodeVerifier) {
80
80
  return { valid: false, error: 'state_not_found' };
81
81
  }
82
+ // The submitted state must equal the stored state, or an attacker can forge an
83
+ // OAuth callback (CSRF). Existence alone is not enough -- compare the values.
84
+ if (state !== storedState) {
85
+ return { valid: false, error: 'state_mismatch' };
86
+ }
82
87
  return { valid: true };
83
88
  }
@@ -1,8 +1,3 @@
1
- /**
2
- * BusyBase Database Adapter - Supabase-compatible database layer for Thatcher
3
- * Supports embedded mode (in-process) and remote mode (HTTP client)
4
- */
5
-
6
1
  import { createLogger } from './logger.js';
7
2
  import { hookEngine } from './hook-engine.js';
8
3
 
@@ -0,0 +1,142 @@
1
+ /*
2
+ * BusyBase-backed audit READ API — replaces the SQLite audit-logger read functions.
3
+ * busybase has no SQL GROUP BY / aggregates, so stats are computed in JS over the
4
+ * audit_logs and permission_audit tables.
5
+ */
6
+
7
+ import { list, create } from '@/lib/busybase-store.js';
8
+ import { genId, now } from '@/lib/id-helpers.js';
9
+
10
+ const parseJson = (val) => { try { return val ? JSON.parse(val) : null; } catch { return null; } };
11
+
12
+ const parsePermRow = (row) => row ? {
13
+ ...row,
14
+ old_permissions: parseJson(row.old_permissions),
15
+ new_permissions: parseJson(row.new_permissions),
16
+ metadata: parseJson(row.metadata),
17
+ before_state: parseJson(row.before_state),
18
+ after_state: parseJson(row.after_state),
19
+ } : null;
20
+
21
+ function ts(r) { return r.timestamp || r.created_at || 0; }
22
+
23
+ export async function getAuditHistory(filters = {}, page = 1, pageSize = 50) {
24
+ const where = {};
25
+ if (filters.entityType) where.entity_type = filters.entityType;
26
+ if (filters.entityId) where.entity_id = filters.entityId;
27
+ if (filters.userId) where.user_id = filters.userId;
28
+ if (filters.action) where.action = filters.action;
29
+ let rows = await list('audit_logs', where);
30
+ if (filters.fromDate) rows = rows.filter(r => ts(r) >= filters.fromDate);
31
+ if (filters.toDate) rows = rows.filter(r => ts(r) <= filters.toDate);
32
+ rows.sort((a, b) => ts(b) - ts(a));
33
+ const total = rows.length;
34
+ const items = rows.slice((page - 1) * pageSize, page * pageSize).map(i => ({
35
+ id: i.id, entityType: i.entity_type, entityId: i.entity_id, action: i.action,
36
+ userId: i.user_id, beforeState: parseJson(i.before_state), afterState: parseJson(i.after_state), createdAt: ts(i),
37
+ }));
38
+ return { items, pagination: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) } };
39
+ }
40
+
41
+ export async function getEntityAuditTrail(entityType, entityId) {
42
+ const rows = (await list('audit_logs', { entity_type: entityType, entity_id: entityId })).sort((a, b) => ts(b) - ts(a));
43
+ return rows.map(i => ({ id: i.id, action: i.action, userId: i.user_id, beforeState: parseJson(i.before_state), afterState: parseJson(i.after_state), createdAt: ts(i) }));
44
+ }
45
+
46
+ async function rangeStats(field, fromDate, toDate) {
47
+ const rows = (await list('audit_logs', {})).filter(r => ts(r) >= fromDate && ts(r) <= toDate);
48
+ const counts = {};
49
+ for (const r of rows) { const k = r[field]; counts[k] = (counts[k] || 0) + 1; }
50
+ return Object.entries(counts).map(([k, count]) => ({ [field]: k, count })).sort((a, b) => b.count - a.count);
51
+ }
52
+ export const getActionStats = (fromDate, toDate) => rangeStats('action', fromDate, toDate);
53
+ export const getUserStats = (fromDate, toDate) => rangeStats('user_id', fromDate, toDate);
54
+
55
+ export async function getPermissionAuditTrail({ entityType, entityId, userId, affectedUserId, limit = 100, offset = 0 }) {
56
+ const where = {};
57
+ if (entityType) where.entity_type = entityType;
58
+ if (entityId) where.entity_id = entityId;
59
+ if (userId) where.user_id = userId;
60
+ if (affectedUserId) where.affected_user_id = affectedUserId;
61
+ const rows = (await list('permission_audit', where)).sort((a, b) => ts(b) - ts(a));
62
+ return rows.slice(offset, offset + limit).map(parsePermRow);
63
+ }
64
+
65
+ export async function getPermissionAuditById(auditId) {
66
+ const rows = await list('permission_audit', { id: auditId });
67
+ return parsePermRow(rows[0] || null);
68
+ }
69
+
70
+ export async function getPermissionAuditStats() {
71
+ const rows = await list('permission_audit', {});
72
+ const users = new Set(rows.map(r => r.user_id).filter(Boolean));
73
+ const types = new Set(rows.map(r => r.entity_type).filter(Boolean));
74
+ const times = rows.map(ts).filter(Boolean);
75
+ return {
76
+ total_audits: rows.length,
77
+ unique_users: users.size,
78
+ entity_types: types.size,
79
+ earliest_change: times.length ? Math.min(...times) : null,
80
+ latest_change: times.length ? Math.max(...times) : null,
81
+ };
82
+ }
83
+
84
+ export async function getPermissionAuditBreakdown(field) {
85
+ const rows = await list('permission_audit', {});
86
+ const counts = {};
87
+ for (const r of rows) { const k = r[field]; counts[k] = (counts[k] || 0) + 1; }
88
+ return Object.entries(counts).map(([k, count]) => ({ [field]: k, count })).sort((a, b) => b.count - a.count);
89
+ }
90
+
91
+ export async function getPermissionAuditByDateRange(startDate, endDate, limit = 100) {
92
+ const rows = (await list('permission_audit', {})).filter(r => ts(r) >= startDate && ts(r) <= endDate).sort((a, b) => ts(b) - ts(a));
93
+ return rows.slice(0, limit).map(parsePermRow);
94
+ }
95
+
96
+ export async function searchPermissionAudit(searchTerm, limit = 100) {
97
+ const q = String(searchTerm || '').toLowerCase();
98
+ const rows = (await list('permission_audit', {}))
99
+ .filter(r => [r.reason, r.entity_type, r.entity_id].some(v => String(v ?? '').toLowerCase().includes(q)))
100
+ .sort((a, b) => ts(b) - ts(a));
101
+ return rows.slice(0, limit).map(parsePermRow);
102
+ }
103
+
104
+ export async function exportPermissionAuditCSV(filters = {}) {
105
+ const rows = await getPermissionAuditTrail({ ...filters, limit: 100000, offset: 0 });
106
+ const cols = ['id', 'user_id', 'entity_type', 'entity_id', 'action', 'reason', 'reason_code', 'timestamp', 'affected_user_id'];
107
+ const esc = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`;
108
+ const header = cols.join(',');
109
+ const lines = rows.map(r => cols.map(c => esc(r[c])).join(','));
110
+ return [header, ...lines].join('\n');
111
+ }
112
+
113
+ export async function logPermissionChange({
114
+ userId, entityType, entityId, action, oldPermissions = null, newPermissions = null,
115
+ reason = null, reasonCode = 'other', affectedUserId = null, ipAddress = null, sessionId = null, metadata = null,
116
+ }) {
117
+ if (!userId || !entityType || !entityId || !action) throw new Error('Missing required audit fields');
118
+ const auditId = genId();
119
+ const timestamp = now();
120
+ await create('permission_audit', {
121
+ id: auditId, user_id: userId, entity_type: entityType, entity_id: entityId, action,
122
+ old_permissions: oldPermissions ? JSON.stringify(oldPermissions) : '',
123
+ new_permissions: newPermissions ? JSON.stringify(newPermissions) : '',
124
+ reason: reason || '', reason_code: reasonCode, timestamp, ip_address: ipAddress || '',
125
+ session_id: sessionId || '', affected_user_id: affectedUserId || '',
126
+ metadata: metadata ? JSON.stringify(metadata) : '', created_at: timestamp, updated_at: timestamp,
127
+ created_by: userId, updated_by: userId,
128
+ });
129
+ return auditId;
130
+ }
131
+
132
+ // Pure diff (no DB) — kept here so consumers import everything audit-read from one place.
133
+ export function getPermissionDiff(oldPerms, newPerms) {
134
+ const o = oldPerms || {}, n = newPerms || {};
135
+ const keys = [...new Set([...Object.keys(o), ...Object.keys(n)])];
136
+ const changes = [];
137
+ for (const k of keys) {
138
+ const before = o[k], after = n[k];
139
+ if (JSON.stringify(before) !== JSON.stringify(after)) changes.push({ field: k, before, after });
140
+ }
141
+ return changes;
142
+ }
@@ -0,0 +1,41 @@
1
+ /*
2
+ * BusyBase-backed audit log — async replacement for the better-sqlite3 audit-logger's
3
+ * logAction(). Writes an audit_logs document through the busybase store.
4
+ *
5
+ * Audit logging must never break the request it records, so logAction is fire-and-
6
+ * forget: it returns immediately and swallows/logs write errors rather than throwing.
7
+ */
8
+
9
+ import { createLogger } from './logger.js';
10
+ import { genId, now } from './id-helpers.js';
11
+
12
+ const log = createLogger('[BusyBaseAudit]');
13
+
14
+ let _client = null;
15
+
16
+ // Wire the busybase client (same instance as the store). Called at bootstrap.
17
+ export function setBusyBaseClient(client) {
18
+ _client = client;
19
+ }
20
+
21
+ export function logAction(entityType, entityId, action, userId, beforeState, afterState) {
22
+ const id = genId();
23
+ const timestamp = now();
24
+ // LanceDB infers column types from values and cannot type an all-null column on the
25
+ // first insert, so use '' (typed string) instead of null for optional text columns.
26
+ const row = {
27
+ id,
28
+ entity_type: entityType,
29
+ entity_id: String(entityId),
30
+ action,
31
+ user_id: userId || '',
32
+ before_state: beforeState ? JSON.stringify(beforeState) : '',
33
+ after_state: afterState ? JSON.stringify(afterState) : '',
34
+ created_at: timestamp,
35
+ };
36
+ if (_client) {
37
+ Promise.resolve(_client.from('audit_logs').insert(row))
38
+ .catch(err => log.error('logAction failed:', { message: err?.message || String(err) }));
39
+ }
40
+ return { id, timestamp };
41
+ }
@@ -0,0 +1,72 @@
1
+ /*
2
+ * Lucia v3 session adapter backed by busybase (replaces @lucia-auth/adapter-sqlite's
3
+ * BetterSqlite3Adapter). Sessions live in the `sessions` table, users in `users`.
4
+ *
5
+ * Lucia stores a session row as { id, user_id, expires_at, ...attributes } and a user
6
+ * row as { id, ...attributes }. Lucia expects expires_at as a JS Date in the objects it
7
+ * receives back; busybase stores it as an epoch-ms number, so we convert at the boundary.
8
+ */
9
+
10
+ import { list, get, create, update, remove } from '@/lib/busybase-store.js';
11
+
12
+ const SESSION = 'sessions';
13
+ const USER = 'users';
14
+
15
+ function rowToSession(row) {
16
+ if (!row) return null;
17
+ const { id, user_id, expires_at, ...rest } = row;
18
+ return { id, userId: user_id, expiresAt: new Date(Number(expires_at)), attributes: rest };
19
+ }
20
+
21
+ function rowToUser(row) {
22
+ if (!row) return null;
23
+ const { id, ...attributes } = row;
24
+ return { id, attributes };
25
+ }
26
+
27
+ export class BusyBaseLuciaAdapter {
28
+ async getSessionAndUser(sessionId) {
29
+ const sRow = await get(SESSION, sessionId);
30
+ if (!sRow) return [null, null];
31
+ const session = rowToSession(sRow);
32
+ const uRow = await get(USER, session.userId);
33
+ return [session, rowToUser(uRow)];
34
+ }
35
+
36
+ async getUserSessions(userId) {
37
+ const rows = await list(SESSION, { user_id: userId });
38
+ return rows.map(rowToSession);
39
+ }
40
+
41
+ async setSession(session) {
42
+ await create(SESSION, {
43
+ id: session.id,
44
+ user_id: session.userId,
45
+ expires_at: session.expiresAt instanceof Date ? session.expiresAt.getTime() : Number(session.expiresAt),
46
+ ...session.attributes,
47
+ });
48
+ }
49
+
50
+ async updateSessionExpiration(sessionId, expiresAt) {
51
+ await update(SESSION, sessionId, {
52
+ expires_at: expiresAt instanceof Date ? expiresAt.getTime() : Number(expiresAt),
53
+ });
54
+ }
55
+
56
+ async deleteSession(sessionId) {
57
+ await remove(SESSION, sessionId);
58
+ }
59
+
60
+ async deleteUserSessions(userId) {
61
+ const rows = await list(SESSION, { user_id: userId });
62
+ for (const r of rows) await remove(SESSION, r.id);
63
+ }
64
+
65
+ async deleteExpiredSessions() {
66
+ const now = Date.now();
67
+ const rows = await list(SESSION, {});
68
+ for (const r of rows) {
69
+ if (Number(r.expires_at) <= now) await remove(SESSION, r.id);
70
+ }
71
+ }
72
+ }