thatcher 1.0.69 → 1.0.71

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thatcher",
3
- "version": "1.0.69",
3
+ "version": "1.0.71",
4
4
  "description": "A config-driven application framework for building data-intensive web apps without code.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -96,7 +96,7 @@ export function getInitialState(spec) {
96
96
  state[key] = false;
97
97
  } else if (field.type === 'int' || field.type === 'decimal' || field.type === 'currency') {
98
98
  state[key] = 0;
99
- } else if (field.type === 'json' || field.type === 'multiselect') {
99
+ } else if (field.type === 'json' || field.type === 'multiselect' || field.type === 'multiref') {
100
100
  state[key] = [];
101
101
  } else if (field.type === 'file' || field.type === 'attachment') {
102
102
  state[key] = null;
@@ -160,10 +160,47 @@ function withCrmDefaults(masterConfig) {
160
160
  return changed ? { ...masterConfig, entities, workflows } : masterConfig;
161
161
  }
162
162
 
163
+ const PROJECT_ENTITY_DEFAULT = {
164
+ label: 'Project',
165
+ label_plural: 'Projects',
166
+ system_entity: true,
167
+ fields: {
168
+ name: { type: 'text', required: true, label: 'Name' },
169
+ description: { type: 'textarea', label: 'Description' },
170
+ owner_id: { type: 'ref', ref: 'user', label: 'Owner' },
171
+ status: { type: 'enum', options: ['planning', 'active', 'on_hold', 'completed', 'cancelled'], default: 'planning', label: 'Status' },
172
+ start_date: { type: 'date', label: 'Start Date' },
173
+ due_date: { type: 'date', label: 'Due Date' },
174
+ },
175
+ };
176
+
177
+ const TASK_ENTITY_DEFAULT = {
178
+ label: 'Task',
179
+ label_plural: 'Tasks',
180
+ system_entity: true,
181
+ fields: {
182
+ name: { type: 'text', required: true, label: 'Name' },
183
+ project_id: { type: 'ref', ref: 'project', label: 'Project' },
184
+ assignee_id: { type: 'ref', ref: 'user', label: 'Assignee' },
185
+ status: { type: 'enum', options: ['todo', 'in_progress', 'blocked', 'done'], default: 'todo', label: 'Status' },
186
+ priority: { type: 'enum', options: ['low', 'medium', 'high', 'urgent'], default: 'medium', label: 'Priority' },
187
+ due_date: { type: 'date', label: 'Due Date' },
188
+ depends_on: { type: 'multiref', ref: 'task', label: 'Depends On' },
189
+ },
190
+ };
191
+
192
+ function withProjectDefaults(masterConfig) {
193
+ const entities = { ...(masterConfig.entities || {}) };
194
+ let changed = false;
195
+ if (!entities.project) { entities.project = PROJECT_ENTITY_DEFAULT; changed = true; }
196
+ if (!entities.task) { entities.task = TASK_ENTITY_DEFAULT; changed = true; }
197
+ return changed ? { ...masterConfig, entities } : masterConfig;
198
+ }
199
+
163
200
  export class ConfigGeneratorEngine {
164
201
  constructor(masterConfig) {
165
202
  if (!masterConfig) throw new Error('[ConfigGeneratorEngine] masterConfig is required');
166
- this.masterConfig = deepFreeze(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig))));
203
+ this.masterConfig = deepFreeze(withProjectDefaults(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig)))));
167
204
  this.specCache = new LRUCache(100);
168
205
  this.debugMode = false;
169
206
  this._plugins = new Map();
@@ -221,6 +221,7 @@ function coerceFieldValue(value, type) {
221
221
  return Boolean(value);
222
222
  case 'json':
223
223
  case 'multiselect':
224
+ case 'multiref':
224
225
  case 'file':
225
226
  case 'attachment':
226
227
  return typeof value === 'string' ? JSON.parse(value) : value;
@@ -11,6 +11,7 @@ export function coerceFieldValue(value, type) {
11
11
  return value === true || value === 'true' || value === 1;
12
12
  case 'json':
13
13
  case 'multiselect':
14
+ case 'multiref':
14
15
  case 'file':
15
16
  case 'attachment':
16
17
  return typeof value === 'string' ? JSON.parse(value) : value;
@@ -36,12 +37,13 @@ export function deserializeField(value, type) {
36
37
  switch (type) {
37
38
  case 'json':
38
39
  case 'multiselect':
40
+ case 'multiref':
39
41
  case 'file':
40
42
  case 'attachment':
41
43
  try {
42
44
  return typeof value === 'string' ? JSON.parse(value) : value;
43
45
  } catch {
44
- return type === 'multiselect' ? [] : {};
46
+ return (type === 'multiselect' || type === 'multiref') ? [] : {};
45
47
  }
46
48
  case 'bool':
47
49
  return Boolean(value);
@@ -76,6 +76,27 @@ export async function validateField(fieldDef, value, options = {}) {
76
76
  }
77
77
  }
78
78
 
79
+ // Multi-ref: array-of-ids referencing another entity (e.g. task.depends_on
80
+ // referencing other task ids) -- every id must resolve to a real record,
81
+ // the same existence guarantee a single 'ref' field already gets.
82
+ if (fieldDef.type === 'multiref' && fieldDef.ref) {
83
+ const arr = Array.isArray(value) ? value : (typeof value === 'string' ? JSON.parse(value) : []);
84
+ try {
85
+ const { getBy } = await import('@/lib/busybase/store');
86
+ const refTable = fieldDef.ref === 'user' ? 'users' : fieldDef.ref;
87
+ for (const refId of arr) {
88
+ if (!(await getBy(refTable, 'id', refId))) {
89
+ return {
90
+ valid: false,
91
+ error: `${fieldDef.ref.charAt(0).toUpperCase() + fieldDef.ref.slice(1)} with id '${refId}' not found`,
92
+ };
93
+ }
94
+ }
95
+ } catch {
96
+ // Reference table might not exist yet
97
+ }
98
+ }
99
+
79
100
  return { valid: true };
80
101
  }
81
102
 
@@ -94,7 +115,7 @@ function validateType(fieldDef, value, fieldName) {
94
115
  if (typeof value !== 'boolean') return `Field '${fieldName}' must be a boolean`;
95
116
  } else if (type === 'timestamp' || type === 'date') {
96
117
  if (isNaN(Number(value))) return `Field '${fieldName}' must be a valid timestamp`;
97
- } else if (type === 'multiselect') {
118
+ } else if (type === 'multiselect' || type === 'multiref') {
98
119
  const arr = Array.isArray(value) ? value : (typeof value === 'string' ? (() => { try { return JSON.parse(value); } catch { return null; } })() : null);
99
120
  if (!Array.isArray(arr)) return `Field '${fieldName}' must be an array`;
100
121
  } else if (type === 'file' || type === 'attachment') {
@@ -176,6 +197,31 @@ async function checkUnique(fieldDef, value, { fieldName, entityName, existingRec
176
197
  return null;
177
198
  }
178
199
 
200
+ // Task completion cannot be a client-side/UI-only rule -- a raw API PATCH
201
+ // setting status=done must be rejected server-side the same as any other
202
+ // write, or dependency ordering is purely cosmetic. depends_on is checked
203
+ // against the CURRENT stored state of each referenced task (never trusting
204
+ // a value the caller might also be trying to change in the same payload).
205
+ async function checkTaskDependencies(entityName, changes, existingRecord) {
206
+ if (entityName !== 'task') return null;
207
+ if (changes.status !== 'done') return null;
208
+
209
+ const dependsOn = changes.depends_on !== undefined ? changes.depends_on : existingRecord?.depends_on;
210
+ const ids = Array.isArray(dependsOn) ? dependsOn : (typeof dependsOn === 'string' && dependsOn ? (() => { try { return JSON.parse(dependsOn); } catch { return []; } })() : []);
211
+ if (!ids.length) return null;
212
+
213
+ const { get } = await import('@/lib/busybase/store');
214
+ const incomplete = [];
215
+ for (const depId of ids) {
216
+ const dep = await get('task', depId);
217
+ if (!dep || dep.status !== 'done') incomplete.push(depId);
218
+ }
219
+ if (incomplete.length) {
220
+ return `Cannot mark done: depends on incomplete task(s) ${incomplete.join(', ')}`;
221
+ }
222
+ return null;
223
+ }
224
+
179
225
  export async function validateUpdate(entityName, changes, existingRecord) {
180
226
  const spec = getSpec(entityName);
181
227
  const errors = {};
@@ -202,6 +248,9 @@ export async function validateUpdate(entityName, changes, existingRecord) {
202
248
  if (uniqueErr && !errors[fieldName]) errors[fieldName] = uniqueErr;
203
249
  }
204
250
 
251
+ const depErr = await checkTaskDependencies(entityName, changes, existingRecord);
252
+ if (depErr) errors.status = depErr;
253
+
205
254
  return errors;
206
255
  }
207
256
 
@@ -405,39 +405,76 @@ async function handleGenericCrud(req, res, entity, id, action, thatcher, configE
405
405
  }
406
406
  break;
407
407
 
408
- case 'POST':
408
+ case 'POST': {
409
409
  // enforceEditPermissions is the authoritative field-level write check --
410
410
  // it throws if the payload touches a field the caller's role isn't
411
411
  // editable_by, so a hand-crafted request bypassing the rendered form
412
412
  // cannot smuggle a restricted field in.
413
413
  permissionService.enforceEditPermissions(user, spec, body);
414
414
  result = await thatcher.create(entity, body, user);
415
+ const { logAction } = await import('../lib/busybase/audit.js');
416
+ logAction(entity, result.id, 'create', user.id, null, result);
415
417
  result = permissionService.filterFields(user, spec, result);
416
418
  status = 201;
417
419
  break;
420
+ }
418
421
 
419
422
  case 'PUT':
420
- case 'PATCH':
423
+ case 'PATCH': {
421
424
  if (!id) {
422
425
  res.writeHead(400);
423
426
  res.end(JSON.stringify({ error: 'ID required' }));
424
427
  return;
425
428
  }
429
+ // Same row/org-access gap the GET path had: an update by id must be
430
+ // scoped the same way a read is, or a user could edit a record
431
+ // outside their org purely because PUT was never checked.
432
+ const existingForUpdate = await thatcher.get(entity, id, { user });
433
+ if (!existingForUpdate) {
434
+ res.writeHead(404);
435
+ res.end(JSON.stringify({ error: 'Not found' }));
436
+ return;
437
+ }
426
438
  permissionService.enforceEditPermissions(user, spec, body);
439
+ {
440
+ // validateUpdate is the authoritative server-side check for both
441
+ // generic field-type constraints AND entity-specific business
442
+ // rules (e.g. task dependency completion) -- this raw-CRUD path
443
+ // never called it at all, so those rules were UI-only until now.
444
+ const { validateUpdate, hasErrors } = await import('../lib/validation/entity-validators.js');
445
+ const updateErrors = await validateUpdate(entity, body, existingForUpdate);
446
+ if (hasErrors(updateErrors)) {
447
+ res.writeHead(400);
448
+ res.end(JSON.stringify({ error: 'Validation failed', errors: updateErrors }));
449
+ return;
450
+ }
451
+ }
427
452
  result = await thatcher.update(entity, id, body, user);
453
+ const { logAction: logUpdate } = await import('../lib/busybase/audit.js');
454
+ logUpdate(entity, id, 'update', user.id, existingForUpdate, result);
428
455
  result = permissionService.filterFields(user, spec, result);
429
456
  break;
457
+ }
430
458
 
431
- case 'DELETE':
459
+ case 'DELETE': {
432
460
  if (!id) {
433
461
  res.writeHead(400);
434
462
  res.end(JSON.stringify({ error: 'ID required' }));
435
463
  return;
436
464
  }
465
+ const existingForDelete = await thatcher.get(entity, id, { user });
466
+ if (!existingForDelete) {
467
+ res.writeHead(404);
468
+ res.end(JSON.stringify({ error: 'Not found' }));
469
+ return;
470
+ }
437
471
  await thatcher.delete(entity, id);
472
+ const { logAction: logDelete } = await import('../lib/busybase/audit.js');
473
+ logDelete(entity, id, 'delete', user.id, existingForDelete, null);
438
474
  res.writeHead(204);
439
475
  res.end();
440
476
  return;
477
+ }
441
478
 
442
479
  default:
443
480
  res.writeHead(405);
@@ -100,12 +100,20 @@ export function renderAuditDashboard(user, auditData = {}) {
100
100
 
101
101
  const statsHtml = `<div class="stats-row">${[
102
102
  { label: 'Total Actions (30d)', value: summary.total_actions || 0 },
103
- { label: 'Permission Grants', value: summary.grants || 0 },
104
- { label: 'Permission Revokes', value: summary.revokes || 0 },
105
- { label: 'Role Changes', value: summary.role_changes || 0 },
103
+ { label: 'Creates', value: summary.creates || 0 },
104
+ { label: 'Updates', value: summary.updates || 0 },
105
+ { label: 'Deletes', value: summary.deletes || 0 },
106
106
  ].map(s => `<div class="stat-card"><div class="stat-card-value">${s.value}</div><div class="stat-card-label">${s.label}</div></div>`).join('')}</div>`;
107
107
 
108
108
  const content = `<div class="page-header"><h1 class="page-title">Audit Dashboard</h1><a href="/permission_audit" class="btn-ghost-clean">View All Records</a></div>
109
+ <form method="GET" style="display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap">
110
+ <input type="text" name="entity_type" placeholder="Entity type" class="form-input" style="width:160px">
111
+ <input type="text" name="user_id" placeholder="User ID" class="form-input" style="width:160px">
112
+ <input type="date" name="from_date" class="form-input">
113
+ <input type="date" name="to_date" class="form-input">
114
+ <button type="submit" class="btn-primary-clean">Filter</button>
115
+ <a href="/admin/audit" class="btn-ghost-clean">Clear</a>
116
+ </form>
109
117
  ${statsHtml}
110
118
  <div class="table-wrap">
111
119
  <div class="table-toolbar">
@@ -77,6 +77,10 @@ function formatFieldValue(k, v, entityName, f) {
77
77
  const arr = Array.isArray(v) ? v : (typeof v === 'string' && v ? (() => { try { return JSON.parse(v) } catch { return [] } })() : [])
78
78
  return arr.length ? arr.map(x => `<span class="pill pill-neutral" style="margin-right:4px">${esc(x)}</span>`).join('') : '-'
79
79
  }
80
+ if (f?.type === 'multiref' && f.ref) {
81
+ const arr = Array.isArray(v) ? v : (typeof v === 'string' && v ? (() => { try { return JSON.parse(v) } catch { return [] } })() : [])
82
+ return arr.length ? arr.map(x => `<a href="/${esc(f.ref)}/${esc(x)}" class="pill pill-neutral" style="margin-right:4px;text-decoration:none">${esc(x)}</a>`).join('') : '-'
83
+ }
80
84
  if (f?.type === 'file' || f?.type === 'attachment') {
81
85
  const meta = typeof v === 'object' && v ? v : (typeof v === 'string' && v ? (() => { try { return JSON.parse(v) } catch { return null } })() : null)
82
86
  return meta?.url ? `<a href="${esc(meta.url)}" class="text-primary hover:underline" target="_blank" rel="noopener">${esc(meta.filename || 'Download')}</a>` : '-'
@@ -84,7 +88,34 @@ function formatFieldValue(k, v, entityName, f) {
84
88
  return fmtVal(v, k)
85
89
  }
86
90
 
87
- export function renderEntityDetail(entityName, item, spec, user) {
91
+ function formatHistoryDiff(before, after) {
92
+ const keys = new Set([...Object.keys(before || {}), ...Object.keys(after || {})])
93
+ const changed = [...keys].filter(k => JSON.stringify(before?.[k]) !== JSON.stringify(after?.[k]) && !['updated_at', '_version'].includes(k))
94
+ if (!changed.length) return ''
95
+ return changed.map(k => {
96
+ const from = before?.[k] !== undefined ? esc(String(before[k])) : '<em>none</em>'
97
+ const to = after?.[k] !== undefined ? esc(String(after[k])) : '<em>none</em>'
98
+ return `<div class="history-field-change"><strong>${esc(k)}</strong>: ${from} &rarr; ${to}</div>`
99
+ }).join('')
100
+ }
101
+
102
+ function renderHistorySection(history) {
103
+ if (!history || !history.length) return ''
104
+ const rows = history.map(h => {
105
+ const when = esc(new Date((h.createdAt || 0) * 1000).toLocaleString('en-ZA'))
106
+ const diff = h.action === 'update' ? formatHistoryDiff(h.beforeState, h.afterState) : ''
107
+ return `<div class="history-entry">
108
+ <div class="history-entry-header"><span class="pill pill-info">${esc(h.action || '-')}</span><span class="history-entry-time">${when}</span><span class="history-entry-user">${esc(h.userId || '-')}</span></div>
109
+ ${diff ? `<div class="history-entry-diff">${diff}</div>` : ''}
110
+ </div>`
111
+ }).join('')
112
+ return `<div class="card-clean" style="margin-top:1.5rem"><div class="card-clean-body">
113
+ <h3 style="margin-bottom:12px">History</h3>
114
+ <div class="history-list">${rows}</div>
115
+ </div></div>`
116
+ }
117
+
118
+ export function renderEntityDetail(entityName, item, spec, user, history = []) {
88
119
  const label = spec?.label || entityName
89
120
  // A field the caller's role isn't visible_to must never reach rendered HTML,
90
121
  // not just be CSS-hidden -- filterFields drops it from `fields` entirely so
@@ -135,6 +166,7 @@ export function renderEntityDetail(entityName, item, spec, user) {
135
166
  <div class="card-clean">
136
167
  <div class="card-clean-body"><div class="detail-grid">${fieldRows || '<p style="color:var(--color-text-muted);font-size:0.875rem;grid-column:1/-1">No details available</p>'}</div></div>
137
168
  </div>
169
+ ${renderHistorySection(history)}
138
170
  `
139
171
 
140
172
  // Canonical gmConfirm (session-13): showDeleteConfirm runs the styled confirm then DELETEs; no bespoke dialog markup/show-hide.
@@ -185,6 +217,11 @@ export function renderEntityForm(entityName, item, spec, user, isNew = false, re
185
217
  const opts = (Array.isArray(f.options) ? f.options : []).map(o => { const ov = typeof o === 'string' ? o : o.value; const ol = typeof o === 'string' ? o : o.label; return `<label style="display:flex;align-items:center;gap:6px;margin:2px 0"><input type="checkbox" name="${k}[]" value="${esc(ov)}" class="checkbox checkbox-primary" ${selected.has(ov)?'checked':''}/><span>${esc(ol)}</span></label>` }).join('')
186
218
  return `<div class="form-field full">${lbl(k,f,f.required)}<div id="field-${k}" data-multiselect="${k}">${opts}</div></div>`
187
219
  }
220
+ if (f.type === 'multiref' && refOptions[k]) {
221
+ const selected = new Set(Array.isArray(val) ? val : (typeof val === 'string' && val ? (() => { try { return JSON.parse(val) } catch { return [] } })() : []))
222
+ const opts = refOptions[k].filter(o => o.value !== item?.id).map(o => `<label style="display:flex;align-items:center;gap:6px;margin:2px 0"><input type="checkbox" name="${k}[]" value="${esc(o.value)}" class="checkbox checkbox-primary" ${selected.has(o.value)?'checked':''}/><span>${esc(o.label)}</span></label>`).join('') || '<p style="font-size:0.8rem;color:var(--color-text-muted)">No options available</p>'
223
+ return `<div class="form-field full">${lbl(k,f,f.required)}<div id="field-${k}" data-multiselect="${k}">${opts}</div></div>`
224
+ }
188
225
  if (f.type === 'currency') {
189
226
  const symbol = esc(f.currency_symbol || '$')
190
227
  const decimalVal = typeof val === 'number' ? (val / 100).toFixed(2) : val
@@ -21,10 +21,16 @@ async function lazyRenderer(name) {
21
21
  return import(`file://${__dirname_adm}${name}?t=${t}`);
22
22
  }
23
23
 
24
- export async function handleAdminPage(normalized, segments, user) {
24
+ export async function handleAdminPage(normalized, segments, user, req) {
25
25
  if (normalized === '/admin/audit') {
26
26
  if (!isPartner(user) && !isManager(user)) return renderAccessDenied(user, 'admin', 'view');
27
- const auditData = await getAuditData();
27
+ const params = req ? new URL(req.url, `http://${req.headers.host}`).searchParams : new URLSearchParams();
28
+ const filters = {};
29
+ if (params.get('entity_type')) filters.entityType = params.get('entity_type');
30
+ if (params.get('user_id')) filters.userId = params.get('user_id');
31
+ if (params.get('from_date')) filters.fromDate = Math.floor(new Date(params.get('from_date')).getTime() / 1000);
32
+ if (params.get('to_date')) filters.toDate = Math.floor(new Date(params.get('to_date')).getTime() / 1000) + 86400;
33
+ const auditData = await getAuditData(filters);
28
34
  return renderAuditDashboard(user, auditData);
29
35
  }
30
36
  if (!isPartner(user)) return renderAccessDenied(user, 'admin', 'view');
@@ -19,7 +19,7 @@ export function resolveEnumOptions(spec) {
19
19
 
20
20
  export async function getRefOptions(spec) {
21
21
  const refOptions = {};
22
- for (const [fieldKey, field] of Object.entries(spec.fields || {}).filter(([, f]) => f.type === 'ref' && f.ref)) {
22
+ for (const [fieldKey, field] of Object.entries(spec.fields || {}).filter(([, f]) => (f.type === 'ref' || f.type === 'multiref') && f.ref)) {
23
23
  try { refOptions[fieldKey] = (await list(field.ref, {})).map(r => ({ value: r.id, label: r.name || r.title || r.label || r.email || r.id })); }
24
24
  catch { refOptions[fieldKey] = []; }
25
25
  }
@@ -76,12 +76,32 @@ export async function getSystemConfig() {
76
76
  } catch { return {}; }
77
77
  }
78
78
 
79
- export async function getAuditData() {
79
+ export async function getAuditData(filters = {}) {
80
80
  try {
81
- const audits = await list('permission_audit', {});
81
+ // audit_logs is the real record-change trail logAction() writes (create/
82
+ // update/delete/transition on every entity); permission_audit is a
83
+ // separate, narrower RBAC grant/revoke/role_change log. This dashboard's
84
+ // own column headers (Entity Type, Entity ID) describe the former, so it
85
+ // reads audit_logs -- the same table entity-renderer.js's per-record
86
+ // History section now reads, giving one consistent audit surface.
87
+ const where = {};
88
+ if (filters.entityType) where.entity_type = filters.entityType;
89
+ if (filters.userId) where.user_id = filters.userId;
90
+ let audits = await list('audit_logs', where);
91
+ if (filters.fromDate) audits = audits.filter(a => (a.timestamp || a.created_at) >= filters.fromDate);
92
+ if (filters.toDate) audits = audits.filter(a => (a.timestamp || a.created_at) <= filters.toDate);
93
+ audits.sort((a, b) => (b.timestamp || b.created_at || 0) - (a.timestamp || a.created_at || 0));
82
94
  const cutoff = Math.floor(Date.now() / 1000) - 30 * 86400;
83
95
  const recent = audits.filter(a => (a.timestamp || a.created_at) > cutoff);
84
- return { summary: { total_actions: recent.length, grants: recent.filter(a => a.action === 'grant').length, revokes: recent.filter(a => a.action === 'revoke').length, role_changes: recent.filter(a => a.action === 'role_change').length }, recentActivity: audits.slice(0, 50) };
96
+ return {
97
+ summary: {
98
+ total_actions: recent.length,
99
+ creates: recent.filter(a => a.action === 'create').length,
100
+ updates: recent.filter(a => a.action === 'update').length,
101
+ deletes: recent.filter(a => a.action === 'delete').length,
102
+ },
103
+ recentActivity: audits.slice(0, 200),
104
+ };
85
105
  } catch { return { summary: {}, recentActivity: [] }; }
86
106
  }
87
107
 
@@ -115,8 +115,26 @@ async function handleGenericEntityView(user, entityName, id, req) {
115
115
  if (item.team_id && user.team_id && item.team_id !== user.team_id && !isPartner(user)) return renderAccessDenied(user, entityName, 'view');
116
116
  if (isClientUser(user) && user.client_id && item.client_id && item.client_id !== user.client_id) return renderAccessDenied(user, entityName, 'view');
117
117
  const [resolvedItem] = resolveRefFields([item], spec);
118
+ // get(...,{user}) above already enforced row/org access for this exact
119
+ // record (a denied/absent record returns null before this point), so
120
+ // fetching its audit trail here is scoped by construction -- there is no
121
+ // separate access check to duplicate for the history view.
122
+ let history = [];
123
+ try {
124
+ const { getEntityAuditTrail } = await import('@/lib/busybase/audit-reads.js');
125
+ const { permissionService } = await import('@/services/permission.service.js');
126
+ const rawHistory = await getEntityAuditTrail(entityName, id);
127
+ // Field-level RBAC must hold for audit diffs too -- a viewer who can't see
128
+ // a restricted field on the live record must not see it in a before/after
129
+ // snapshot either, or field-level RBAC would be trivially bypassed via history.
130
+ history = rawHistory.map(h => ({
131
+ ...h,
132
+ beforeState: h.beforeState ? permissionService.filterFields(user, spec, h.beforeState) : null,
133
+ afterState: h.afterState ? permissionService.filterFields(user, spec, h.afterState) : null,
134
+ }));
135
+ } catch {}
118
136
  const { renderEntityDetail: lazyEntityDetail } = await lazyRenderer('entity-renderer.js');
119
- return lazyEntityDetail(entityName, resolvedItem, spec, user);
137
+ return lazyEntityDetail(entityName, resolvedItem, spec, user, history);
120
138
  }
121
139
  async function handleClientSubRoute(user, clientId, subRoute) {
122
140
  if (!canView(user, 'client')) return renderAccessDenied(user, 'client', 'view');
@@ -162,7 +180,7 @@ export async function handlePage(pathname, req, res) {
162
180
  if (normalized === '/unauthorized') return renderAccessDenied(user, 'system', 'access');
163
181
  if (normalized === '/notifications') { let notifs=[]; try{notifs=await list('notification',{user_id:user.id},{sort:{field:'created_at',dir:'DESC'},limit:100,user})}catch{} const{renderNotificationsPage}=await lazyRenderer('notifications-renderer.js'); return renderNotificationsPage(user,notifs); }
164
182
  if (normalized === '/' || normalized === '/dashboard') return renderDashboard(user, await getDashboardStats(user));
165
- if (normalized.startsWith('/admin/') || normalized === '/admin/jobs') return handleAdminPage(normalized, segments, user);
183
+ if (normalized.startsWith('/admin/') || normalized === '/admin/jobs') return handleAdminPage(normalized, segments, user, req);
166
184
  if (segments[0] === 'client' && segments.length === 3 && ['dashboard', 'users', 'progress'].includes(segments[2])) return handleClientSubRoute(user, segments[1], segments[2]);
167
185
  if (isClerk(user) && segments.length >= 1 && ['user', 'team'].includes(segments[0])) return renderAccessDenied(user, segments[0], 'list');
168
186
  if (normalized === '/mwr' || normalized === '/mwr/home') {