thatcher 1.0.7 → 1.0.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -16
- package/package.json +12 -18
- package/src/adapters/google-auth.js +4 -43
- package/src/adapters/google-drive.js +12 -67
- package/src/app/api/audit/dashboard/route.js +14 -11
- package/src/app/api/audit/logs/route.js +2 -2
- package/src/app/api/audit/permissions/[id]/route.js +6 -3
- package/src/app/api/audit/permissions/route.js +10 -7
- package/src/app/api/audit/permissions/stats/route.js +8 -5
- package/src/app/api/audit/route.js +4 -4
- package/src/app/api/audit/stats/route.js +1 -1
- package/src/app/api/auth/google/callback/route.js +5 -2
- package/src/app/api/auth/google/route.js +4 -1
- package/src/app/api/auth/login/route.js +5 -2
- package/src/app/api/auth/mwr-bridge/route.js +11 -21
- package/src/app/api/auth/password-reset/route.js +15 -14
- package/src/app/api/cron/trigger/route.js +4 -1
- package/src/app/api/debug/[[...path]]/route.js +6 -5
- package/src/app/api/debug/sqlite/route.js +7 -12
- package/src/app/api/debug/sync/route.js +5 -5
- package/src/app/api/domains/route.js +4 -1
- package/src/app/api/email/allocate/batch/route.js +19 -29
- package/src/app/api/email/allocate/route.js +16 -17
- package/src/app/api/email/receive/route.js +31 -42
- package/src/app/api/email/send/route.js +28 -16
- package/src/app/api/email/unallocated/route.js +10 -13
- package/src/app/api/files/[id]/route.js +4 -1
- package/src/app/api/formula/[[...path]]/route.js +0 -4
- package/src/app/api/health/route.js +10 -9
- package/src/app/api/metrics/route.js +1 -1
- package/src/cli.js +4 -4
- package/src/config/config-loader.js +1 -21
- package/src/config/constants.js +0 -35
- package/src/config/env.js +0 -30
- package/src/config/spec-helpers.js +6 -95
- package/src/engine.js +23 -0
- package/src/engine.server.js +8 -78
- package/src/index.js +42 -67
- package/src/lib/alert-manager.js +7 -3
- package/src/lib/api-helpers.js +4 -3
- package/src/lib/api.js +10 -10
- package/src/lib/audit-logger-enhanced.js +77 -83
- package/src/lib/auth-middleware.js +1 -32
- package/src/lib/auth-route-helpers.js +5 -0
- package/src/lib/busybase-adapter.js +0 -5
- package/src/lib/busybase-audit-reads.js +142 -0
- package/src/lib/busybase-audit.js +41 -0
- package/src/lib/busybase-lucia-adapter.js +72 -0
- package/src/lib/busybase-store.js +338 -0
- package/src/lib/config-field-helpers.js +0 -17
- package/src/lib/config-generator-engine.js +19 -36
- package/src/lib/config-helpers.js +0 -21
- package/src/lib/crud-action-helpers.js +2 -2
- package/src/lib/crud-factory.js +1 -16
- package/src/lib/crud-handlers.js +17 -52
- package/src/lib/csrf-protection.js +5 -2
- package/src/lib/date-utils.js +3 -127
- package/src/lib/debug-registry.js +0 -5
- package/src/lib/domain-loader.js +4 -1
- package/src/lib/email-sender.js +25 -23
- package/src/lib/error-handler.js +5 -41
- package/src/lib/error-recovery.js +10 -7
- package/src/lib/error-resilience.js +7 -5
- package/src/lib/events-engine.js +9 -6
- package/src/lib/export-sink.js +0 -5
- package/src/lib/field-iterator.js +7 -76
- package/src/lib/field-registry.js +0 -22
- package/src/lib/generic-crud-handler.js +0 -16
- package/src/lib/hook-engine.js +5 -53
- package/src/lib/hot-reload/checkpoint.js +5 -2
- package/src/lib/hot-reload/promise-container.js +7 -3
- package/src/lib/hot-reload/route-wrapper.js +4 -1
- package/src/lib/hot-reload/supervisor.js +6 -3
- package/src/lib/hyperformula-service.js +0 -5
- package/src/lib/id-helpers.js +10 -0
- package/src/lib/index.js +4 -7
- package/src/lib/log-aggregator.js +0 -1
- package/src/lib/logger.js +0 -15
- package/src/lib/metrics-collector.js +2 -46
- package/src/lib/next-compat.js +15 -1
- package/src/lib/observability-bootstrap.js +0 -5
- package/src/lib/perf-monitor.js +4 -1
- package/src/lib/perf-profiler.js +0 -5
- package/src/lib/query-string-adapter.js +5 -47
- package/src/lib/realtime-server.js +4 -26
- package/src/lib/request-tracing.js +0 -5
- package/src/lib/resource-monitor.js +4 -1
- package/src/lib/response-formatter.js +0 -38
- package/src/lib/route-helpers.js +3 -1
- package/src/lib/safe-json.js +5 -1
- package/src/lib/server-bootstrap.js +10 -9
- package/src/lib/stage-pipeline.js +2 -2
- package/src/lib/state-transport-client.js +4 -1
- package/src/lib/static-server.js +2 -2
- package/src/lib/status-helpers.js +2 -51
- package/src/lib/tracing.js +0 -5
- package/src/lib/utils.js +6 -67
- package/src/lib/validate.js +5 -88
- package/src/lib/validators.js +0 -38
- package/src/lib/with-audit-logging.js +1 -1
- package/src/lib/workflow-engine.js +10 -77
- package/src/lib/xstate-workflow-engine.js +3 -9
- package/src/plugins/index.js +5 -3
- package/src/server/server.js +26 -27
- package/src/services/collaborator-role.service.js +21 -77
- package/src/services/email-sender.js +7 -34
- package/src/services/notification-engine.js +12 -33
- package/src/services/permission.service.js +10 -73
- package/src/ui/advanced-search-renderer.js +3 -2
- package/src/ui/advanced-widgets.js +1 -1
- package/src/ui/auth-pages.js +10 -9
- package/src/ui/auth-styles.js +1 -1
- package/src/ui/client.js +1 -1
- package/src/ui/component-engine.js +1 -1
- package/src/ui/dashboard-renderer.js +5 -5
- package/src/ui/dialog-engine.js +6 -9
- package/src/ui/dialog-factory.js +2 -2
- package/src/ui/engagement-cards.js +7 -7
- package/src/ui/engagement-dialogs.js +2 -3
- package/src/ui/engagement-grid-renderer.js +20 -19
- package/src/ui/entity-renderer.js +56 -42
- package/src/ui/file-dialogs.js +25 -9
- package/src/ui/flexup-report-renderer.js +21 -18
- package/src/ui/format-helpers.js +27 -28
- package/src/ui/highlight-threading-renderer.js +9 -9
- package/src/ui/job-management-renderer.js +18 -16
- package/src/ui/layout.js +51 -25
- package/src/ui/ml-console-renderer.js +15 -16
- package/src/ui/monitoring-dashboard-client.js +9 -8
- package/src/ui/notifications-renderer.js +11 -10
- package/src/ui/page-handler-admin.js +32 -23
- package/src/ui/page-handler-helpers.js +27 -23
- package/src/ui/page-handler-reviews.js +71 -35
- package/src/ui/page-handler-rfi.js +28 -14
- package/src/ui/page-handler.js +65 -48
- package/src/ui/picker-dialogs.js +26 -19
- package/src/ui/render-helpers.js +84 -46
- package/src/ui/review-comparison-renderer.js +8 -7
- package/src/ui/review-detail-panels.js +8 -9
- package/src/ui/review-detail-renderer.js +3 -3
- package/src/ui/review-detail-script.js +13 -12
- package/src/ui/review-mwr-renderer.js +6 -5
- package/src/ui/review-renderer.js +7 -6
- package/src/ui/review-widgets.js +3 -3
- package/src/ui/rfi-detail-renderer.js +37 -20
- package/src/ui/rfi-report-renderer.js +14 -9
- package/src/ui/settings-renderer-advanced.js +21 -18
- package/src/ui/settings-renderer-advanced2.js +20 -19
- package/src/ui/settings-renderer-teams.js +4 -4
- package/src/ui/settings-renderer.js +4 -4
- package/src/ui/spacing-system.js +5 -2
- package/src/ui/standalone-login.js +2 -2
- package/src/ui/styles.css +2 -1
- package/src/ui/styles2.css +75 -5
- package/src/ui/test-page.js +4 -4
- package/src/ui/widgets.js +20 -15
- package/src/lib/audit-logger.js +0 -193
- package/src/lib/database-core.js +0 -255
- package/src/lib/database-migrations.js +0 -350
- package/src/lib/db-backup.js +0 -97
- package/src/lib/error-boundary.js +0 -134
- package/src/lib/errors.js +0 -69
- package/src/lib/health-monitor.js +0 -134
- package/src/lib/index-optimizer.js +0 -96
- package/src/lib/query-engine-write.js +0 -221
- package/src/lib/query-engine.js +0 -446
- package/src/ui/collaboration-dialogs.js +0 -31
- package/src/ui/global-tags.js +0 -144
- package/src/ui/idle-logout.js +0 -156
- package/src/ui/letter-dialogs.js +0 -37
- package/src/ui/password-reset-page.js +0 -146
- package/src/ui/perf-renderer.js +0 -71
- package/src/ui/rfi-renderer.js +0 -194
- package/src/ui/validation-ui.js +0 -147
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { createLogger } from './logger.js';
|
|
2
|
+
import { genId, now } from '@/lib/id-helpers';
|
|
3
3
|
|
|
4
|
-
const
|
|
5
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
85
|
-
const
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
99
|
+
export const rotateLogsOlderThan = async (daysOld = 90) => {
|
|
100
|
+
const cutoff = now() - (daysOld * 24 * 60 * 60);
|
|
94
101
|
const archiveId = genId();
|
|
95
|
-
const archived =
|
|
102
|
+
const archived = (await list('structured_logs', {})).filter(r => t(r) < cutoff);
|
|
96
103
|
if (archived.length > 0) {
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
}
|
|
@@ -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
|
+
}
|