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.
- package/LICENSE +21 -0
- package/README.md +398 -0
- package/package.json +73 -0
- package/src/adapters/google-auth.js +148 -0
- package/src/adapters/google-drive.js +209 -0
- package/src/app/api/[entity]/[[...path]]/route.js +33 -0
- package/src/app/api/audit/dashboard/route.js +77 -0
- package/src/app/api/audit/logs/route.js +46 -0
- package/src/app/api/audit/permissions/[id]/route.js +37 -0
- package/src/app/api/audit/permissions/route.js +93 -0
- package/src/app/api/audit/permissions/stats/route.js +29 -0
- package/src/app/api/audit/route.js +79 -0
- package/src/app/api/audit/stats/route.js +18 -0
- package/src/app/api/auth/google/callback/route.js +94 -0
- package/src/app/api/auth/google/route.js +58 -0
- package/src/app/api/auth/login/route.js +121 -0
- package/src/app/api/auth/logout/route.js +52 -0
- package/src/app/api/auth/me/route.js +16 -0
- package/src/app/api/auth/mwr-bridge/route.js +77 -0
- package/src/app/api/auth/password-reset/route.js +65 -0
- package/src/app/api/cron/trigger/route.js +58 -0
- package/src/app/api/csrf-token/route.js +8 -0
- package/src/app/api/debug/config/route.js +22 -0
- package/src/app/api/debug/hooks/route.js +16 -0
- package/src/app/api/debug/plugins/route.js +20 -0
- package/src/app/api/debug/sqlite/route.js +21 -0
- package/src/app/api/debug/sync/route.js +29 -0
- package/src/app/api/debug/workflow/route.js +20 -0
- package/src/app/api/domains/[domain]/route.js +26 -0
- package/src/app/api/domains/route.js +21 -0
- package/src/app/api/email/allocate/batch/route.js +106 -0
- package/src/app/api/email/allocate/route.js +141 -0
- package/src/app/api/email/receive/route.js +158 -0
- package/src/app/api/email/route.js +3 -0
- package/src/app/api/email/send/route.js +77 -0
- package/src/app/api/email/unallocated/route.js +47 -0
- package/src/app/api/files/[id]/route.js +38 -0
- package/src/app/api/health/route.js +95 -0
- package/src/app/api/metrics/route.js +73 -0
- package/src/app/api/monitoring/dashboard/route.js +18 -0
- package/src/cli.js +243 -0
- package/src/config/config-loader.js +112 -0
- package/src/config/constants.js +127 -0
- package/src/config/env.js +164 -0
- package/src/config/spec-helpers.js +232 -0
- package/src/engine.server.js +212 -0
- package/src/index.js +368 -0
- package/src/lib/accessibility.js +162 -0
- package/src/lib/action-factory.js +34 -0
- package/src/lib/action-utils.js +21 -0
- package/src/lib/alert-manager.js +189 -0
- package/src/lib/api-error-wrapper.js +125 -0
- package/src/lib/api-helpers.js +53 -0
- package/src/lib/api.js +82 -0
- package/src/lib/audit-logger-enhanced.js +117 -0
- package/src/lib/audit-logger.js +193 -0
- package/src/lib/auth-middleware.js +102 -0
- package/src/lib/auth-route-helpers.js +83 -0
- package/src/lib/business-rules-engine.js +86 -0
- package/src/lib/compression.js +44 -0
- package/src/lib/config-field-helpers.js +91 -0
- package/src/lib/config-generator-engine.js +445 -0
- package/src/lib/config-helpers.js +120 -0
- package/src/lib/connection-guard.js +79 -0
- package/src/lib/crud-action-helpers.js +34 -0
- package/src/lib/crud-factory.js +83 -0
- package/src/lib/crud-handlers.js +244 -0
- package/src/lib/csrf-protection.js +63 -0
- package/src/lib/database-core.js +258 -0
- package/src/lib/database-migrations.js +96 -0
- package/src/lib/date-utils.js +159 -0
- package/src/lib/db-backup.js +97 -0
- package/src/lib/db-monitor.js +127 -0
- package/src/lib/domain-loader.js +82 -0
- package/src/lib/email-sender.js +100 -0
- package/src/lib/error-boundary.js +134 -0
- package/src/lib/error-handler.js +84 -0
- package/src/lib/error-recovery.js +190 -0
- package/src/lib/error-resilience.js +130 -0
- package/src/lib/errors.js +69 -0
- package/src/lib/events-engine.js +182 -0
- package/src/lib/field-iterator.js +50 -0
- package/src/lib/field-registry.js +68 -0
- package/src/lib/field-types.js +154 -0
- package/src/lib/generic-crud-handler.js +32 -0
- package/src/lib/health-monitor.js +134 -0
- package/src/lib/hook-engine.js +169 -0
- package/src/lib/hot-reload/cache-invalidator.js +115 -0
- package/src/lib/hot-reload/checkpoint.js +95 -0
- package/src/lib/hot-reload/debug-exposure.js +67 -0
- package/src/lib/hot-reload/directory-watcher.js +96 -0
- package/src/lib/hot-reload/index.js +50 -0
- package/src/lib/hot-reload/mutex.js +75 -0
- package/src/lib/hot-reload/promise-container.js +66 -0
- package/src/lib/hot-reload/route-wrapper.js +46 -0
- package/src/lib/hot-reload/safe-error.js +51 -0
- package/src/lib/hot-reload/supervisor.js +161 -0
- package/src/lib/hot-reload/timeout-wrapper.js +52 -0
- package/src/lib/http-methods-factory.js +25 -0
- package/src/lib/index-optimizer.js +96 -0
- package/src/lib/index.js +35 -0
- package/src/lib/list-data-transform.js +39 -0
- package/src/lib/log-aggregator.js +116 -0
- package/src/lib/logger.js +55 -0
- package/src/lib/metrics-collector.js +102 -0
- package/src/lib/minifier.js +19 -0
- package/src/lib/monitoring-init.js +67 -0
- package/src/lib/next-compat.js +80 -0
- package/src/lib/next-polyfills.js +135 -0
- package/src/lib/perf-monitor.js +91 -0
- package/src/lib/progress-components.js +181 -0
- package/src/lib/query-cache.js +126 -0
- package/src/lib/query-engine-write.js +221 -0
- package/src/lib/query-engine.js +399 -0
- package/src/lib/query-perf.js +117 -0
- package/src/lib/query-string-adapter.js +75 -0
- package/src/lib/realtime-server.js +67 -0
- package/src/lib/render-cache.js +61 -0
- package/src/lib/request-tracker.js +43 -0
- package/src/lib/resource-hints.js +29 -0
- package/src/lib/resource-monitor.js +117 -0
- package/src/lib/response-formatter.js +80 -0
- package/src/lib/route-helpers.js +33 -0
- package/src/lib/route-resolver.js +142 -0
- package/src/lib/safe-json.js +8 -0
- package/src/lib/server-bootstrap.js +71 -0
- package/src/lib/stage-pipeline.js +153 -0
- package/src/lib/state-protocol.js +171 -0
- package/src/lib/state-transport-client.js +169 -0
- package/src/lib/state-transport-reconnect.js +121 -0
- package/src/lib/state-transport-server.js +181 -0
- package/src/lib/static-server.js +97 -0
- package/src/lib/status-helpers.js +98 -0
- package/src/lib/universal-handler.js +7 -0
- package/src/lib/utils.js +93 -0
- package/src/lib/validate.js +197 -0
- package/src/lib/validation/business-validators.js +61 -0
- package/src/lib/validation/csrf.js +51 -0
- package/src/lib/validation/file-validators.js +34 -0
- package/src/lib/validation/format-validators.js +106 -0
- package/src/lib/validation/index.js +19 -0
- package/src/lib/validation/rate-limit.js +31 -0
- package/src/lib/validation/security-validators.js +78 -0
- package/src/lib/validation-middleware.js +133 -0
- package/src/lib/validators.js +105 -0
- package/src/lib/with-audit-logging.js +63 -0
- package/src/lib/with-error-handler.js +31 -0
- package/src/lib/workflow-engine.js +250 -0
- package/src/server/server.js +305 -0
- package/src/services/collaborator-role.service.js +205 -0
- package/src/services/email-sender.js +105 -0
- package/src/services/notification-engine.js +110 -0
- package/src/services/permission.service.js +181 -0
- package/src/ui/advanced-search-renderer.js +42 -0
- package/src/ui/advanced-widgets.js +47 -0
- package/src/ui/auth-pages.js +114 -0
- package/src/ui/auth-styles.js +53 -0
- package/src/ui/client.js +99 -0
- package/src/ui/collaboration-dialogs.js +31 -0
- package/src/ui/common-handlers.js +156 -0
- package/src/ui/component-engine.js +103 -0
- package/src/ui/dashboard-renderer.js +150 -0
- package/src/ui/dialog-engine.js +147 -0
- package/src/ui/dialog-factory.js +38 -0
- package/src/ui/engagement-cards.js +76 -0
- package/src/ui/engagement-dialogs.js +100 -0
- package/src/ui/engagement-grid-renderer.js +109 -0
- package/src/ui/entity-renderer.js +176 -0
- package/src/ui/event-delegation.js +108 -0
- package/src/ui/fetch-json.js +24 -0
- package/src/ui/file-dialogs.js +81 -0
- package/src/ui/flexup-report-renderer.js +173 -0
- package/src/ui/format-helpers.js +102 -0
- package/src/ui/global-tags.js +144 -0
- package/src/ui/highlight-threading-renderer.js +176 -0
- package/src/ui/idle-logout.js +156 -0
- package/src/ui/job-management-renderer.js +61 -0
- package/src/ui/layout.js +219 -0
- package/src/ui/letter-dialogs.js +37 -0
- package/src/ui/ml-console-renderer.js +108 -0
- package/src/ui/monitoring-dashboard-client.js +136 -0
- package/src/ui/monitoring-dashboard.js +134 -0
- package/src/ui/notifications-renderer.js +51 -0
- package/src/ui/page-handler-admin.js +121 -0
- package/src/ui/page-handler-helpers.js +111 -0
- package/src/ui/page-handler-reviews.js +165 -0
- package/src/ui/page-handler-rfi.js +42 -0
- package/src/ui/page-handler.js +210 -0
- package/src/ui/password-reset-page.js +146 -0
- package/src/ui/perf-helpers.js +96 -0
- package/src/ui/perf-renderer.js +71 -0
- package/src/ui/permissions-ui.js +163 -0
- package/src/ui/picker-dialogs.js +100 -0
- package/src/ui/render-helpers.js +124 -0
- package/src/ui/renderer.js +35 -0
- package/src/ui/review-comparison-renderer.js +58 -0
- package/src/ui/review-detail-panels.js +71 -0
- package/src/ui/review-detail-renderer.js +170 -0
- package/src/ui/review-detail-script.js +95 -0
- package/src/ui/review-mwr-renderer.js +113 -0
- package/src/ui/review-renderer.js +202 -0
- package/src/ui/review-widgets.js +88 -0
- package/src/ui/review-zone-nav.js +12 -0
- package/src/ui/rfi-detail-renderer.js +191 -0
- package/src/ui/rfi-renderer.js +194 -0
- package/src/ui/rfi-report-renderer.js +56 -0
- package/src/ui/rippleui.css +1 -0
- package/src/ui/settings-renderer-advanced.js +195 -0
- package/src/ui/settings-renderer-advanced2.js +158 -0
- package/src/ui/settings-renderer-teams.js +112 -0
- package/src/ui/settings-renderer.js +166 -0
- package/src/ui/spacing-system.js +155 -0
- package/src/ui/standalone-login.js +109 -0
- package/src/ui/styles.css +2530 -0
- package/src/ui/styles2.css +1602 -0
- package/src/ui/test-page.js +23 -0
- package/src/ui/validation-rules.js +73 -0
- package/src/ui/validation-ui.js +147 -0
- package/src/ui/virtual-scroll.js +107 -0
- package/src/ui/webjsx.js +61 -0
- 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
|
+
}
|