thatcher 1.0.69 → 1.0.70

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.70",
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",
@@ -405,39 +405,63 @@ 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);
427
439
  result = await thatcher.update(entity, id, body, user);
440
+ const { logAction: logUpdate } = await import('../lib/busybase/audit.js');
441
+ logUpdate(entity, id, 'update', user.id, existingForUpdate, result);
428
442
  result = permissionService.filterFields(user, spec, result);
429
443
  break;
444
+ }
430
445
 
431
- case 'DELETE':
446
+ case 'DELETE': {
432
447
  if (!id) {
433
448
  res.writeHead(400);
434
449
  res.end(JSON.stringify({ error: 'ID required' }));
435
450
  return;
436
451
  }
452
+ const existingForDelete = await thatcher.get(entity, id, { user });
453
+ if (!existingForDelete) {
454
+ res.writeHead(404);
455
+ res.end(JSON.stringify({ error: 'Not found' }));
456
+ return;
457
+ }
437
458
  await thatcher.delete(entity, id);
459
+ const { logAction: logDelete } = await import('../lib/busybase/audit.js');
460
+ logDelete(entity, id, 'delete', user.id, existingForDelete, null);
438
461
  res.writeHead(204);
439
462
  res.end();
440
463
  return;
464
+ }
441
465
 
442
466
  default:
443
467
  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">
@@ -84,7 +84,34 @@ function formatFieldValue(k, v, entityName, f) {
84
84
  return fmtVal(v, k)
85
85
  }
86
86
 
87
- export function renderEntityDetail(entityName, item, spec, user) {
87
+ function formatHistoryDiff(before, after) {
88
+ const keys = new Set([...Object.keys(before || {}), ...Object.keys(after || {})])
89
+ const changed = [...keys].filter(k => JSON.stringify(before?.[k]) !== JSON.stringify(after?.[k]) && !['updated_at', '_version'].includes(k))
90
+ if (!changed.length) return ''
91
+ return changed.map(k => {
92
+ const from = before?.[k] !== undefined ? esc(String(before[k])) : '<em>none</em>'
93
+ const to = after?.[k] !== undefined ? esc(String(after[k])) : '<em>none</em>'
94
+ return `<div class="history-field-change"><strong>${esc(k)}</strong>: ${from} &rarr; ${to}</div>`
95
+ }).join('')
96
+ }
97
+
98
+ function renderHistorySection(history) {
99
+ if (!history || !history.length) return ''
100
+ const rows = history.map(h => {
101
+ const when = esc(new Date((h.createdAt || 0) * 1000).toLocaleString('en-ZA'))
102
+ const diff = h.action === 'update' ? formatHistoryDiff(h.beforeState, h.afterState) : ''
103
+ return `<div class="history-entry">
104
+ <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>
105
+ ${diff ? `<div class="history-entry-diff">${diff}</div>` : ''}
106
+ </div>`
107
+ }).join('')
108
+ return `<div class="card-clean" style="margin-top:1.5rem"><div class="card-clean-body">
109
+ <h3 style="margin-bottom:12px">History</h3>
110
+ <div class="history-list">${rows}</div>
111
+ </div></div>`
112
+ }
113
+
114
+ export function renderEntityDetail(entityName, item, spec, user, history = []) {
88
115
  const label = spec?.label || entityName
89
116
  // A field the caller's role isn't visible_to must never reach rendered HTML,
90
117
  // not just be CSS-hidden -- filterFields drops it from `fields` entirely so
@@ -135,6 +162,7 @@ export function renderEntityDetail(entityName, item, spec, user) {
135
162
  <div class="card-clean">
136
163
  <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
164
  </div>
165
+ ${renderHistorySection(history)}
138
166
  `
139
167
 
140
168
  // Canonical gmConfirm (session-13): showDeleteConfirm runs the styled confirm then DELETEs; no bespoke dialog markup/show-hide.
@@ -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');
@@ -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') {