thatcher 1.0.67 → 1.0.69

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.67",
3
+ "version": "1.0.69",
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",
package/src/index.js CHANGED
@@ -305,9 +305,9 @@ export class Thatcher {
305
305
  return list(entity, where, opts);
306
306
  }
307
307
 
308
- async get(entity, id) {
308
+ async get(entity, id, opts = {}) {
309
309
  const { get } = await import(resolveModule('./lib/busybase/store.js'));
310
- return get(entity, id);
310
+ return get(entity, id, opts);
311
311
  }
312
312
 
313
313
  async create(entity, data, user) {
@@ -106,10 +106,64 @@ function withWebhookDefaults(masterConfig) {
106
106
  return changed ? { ...masterConfig, entities } : masterConfig;
107
107
  }
108
108
 
109
+ const LEAD_ENTITY_DEFAULT = {
110
+ label: 'Lead',
111
+ label_plural: 'Leads',
112
+ system_entity: true,
113
+ fields: {
114
+ name: { type: 'text', required: true, label: 'Name' },
115
+ company: { type: 'text', label: 'Company' },
116
+ email: { type: 'email', label: 'Email' },
117
+ phone: { type: 'text', label: 'Phone' },
118
+ source: { type: 'text', label: 'Source' },
119
+ status: { type: 'enum', options: ['new', 'contacted', 'qualified', 'disqualified'], default: 'new', label: 'Status' },
120
+ owner_id: { type: 'ref', ref: 'user', label: 'Owner' },
121
+ notes: { type: 'textarea', label: 'Notes' },
122
+ },
123
+ };
124
+
125
+ const OPPORTUNITY_PIPELINE_WORKFLOW = {
126
+ state_field: 'stage',
127
+ stages: [
128
+ { name: 'prospecting', label: 'Prospecting', forward: ['qualification'], backward: [] },
129
+ { name: 'qualification', label: 'Qualification', forward: ['proposal'], backward: ['prospecting'] },
130
+ { name: 'proposal', label: 'Proposal', forward: ['negotiation'], backward: ['qualification'] },
131
+ { name: 'negotiation', label: 'Negotiation', forward: ['won', 'lost'], backward: ['proposal'] },
132
+ { name: 'won', label: 'Won', forward: [], backward: [] },
133
+ { name: 'lost', label: 'Lost', forward: [], backward: [] },
134
+ ],
135
+ };
136
+
137
+ const OPPORTUNITY_ENTITY_DEFAULT = {
138
+ label: 'Opportunity',
139
+ label_plural: 'Opportunities',
140
+ system_entity: true,
141
+ workflow: 'opportunity_pipeline',
142
+ fields: {
143
+ name: { type: 'text', required: true, label: 'Name' },
144
+ lead_id: { type: 'ref', ref: 'lead', label: 'Lead' },
145
+ value: { type: 'currency', label: 'Value' },
146
+ stage: { type: 'enum', options: ['prospecting', 'qualification', 'proposal', 'negotiation', 'won', 'lost'], default: 'prospecting', label: 'Stage' },
147
+ expected_close_date: { type: 'date', label: 'Expected Close Date' },
148
+ owner_id: { type: 'ref', ref: 'user', label: 'Owner' },
149
+ probability: { type: 'number', min: 0, max: 100, label: 'Probability (%)' },
150
+ },
151
+ };
152
+
153
+ function withCrmDefaults(masterConfig) {
154
+ const entities = { ...(masterConfig.entities || {}) };
155
+ const workflows = { ...(masterConfig.workflows || {}) };
156
+ let changed = false;
157
+ if (!entities.lead) { entities.lead = LEAD_ENTITY_DEFAULT; changed = true; }
158
+ if (!entities.opportunity) { entities.opportunity = OPPORTUNITY_ENTITY_DEFAULT; changed = true; }
159
+ if (!workflows.opportunity_pipeline) { workflows.opportunity_pipeline = OPPORTUNITY_PIPELINE_WORKFLOW; changed = true; }
160
+ return changed ? { ...masterConfig, entities, workflows } : masterConfig;
161
+ }
162
+
109
163
  export class ConfigGeneratorEngine {
110
164
  constructor(masterConfig) {
111
165
  if (!masterConfig) throw new Error('[ConfigGeneratorEngine] masterConfig is required');
112
- this.masterConfig = deepFreeze(withWebhookDefaults(withMultiTenancyDefaults(masterConfig)));
166
+ this.masterConfig = deepFreeze(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig))));
113
167
  this.specCache = new LRUCache(100);
114
168
  this.debugMode = false;
115
169
  this._plugins = new Map();
@@ -459,6 +513,21 @@ export class ConfigGeneratorEngine {
459
513
  };
460
514
  }
461
515
 
516
+ // Field-level RBAC: a field's visible_to/editable_by (already present on
517
+ // spec.fields[key] via the `...field` spread above) is compiled into
518
+ // spec.fieldPermissions, the shape permission.service.js's checkFieldAccess
519
+ // already reads. A field with neither key contributes no entry here, so
520
+ // checkFieldAccess's `if (!perm) return true` keeps every role's access
521
+ // exactly as it was before this feature existed -- no regression for the
522
+ // overwhelming majority of fields that never set either key.
523
+ for (const [key, field] of Object.entries(spec.fields)) {
524
+ if (!field.visible_to && !field.editable_by) continue;
525
+ spec.fieldPermissions = spec.fieldPermissions || {};
526
+ spec.fieldPermissions[key] = {};
527
+ if (field.visible_to) spec.fieldPermissions[key].view = field.visible_to;
528
+ if (field.editable_by) spec.fieldPermissions[key].edit = field.editable_by;
529
+ }
530
+
462
531
  if (entityDef.permission_template) {
463
532
  const matrix = this.getPermissionTemplate(entityDef.permission_template);
464
533
  const access = {};
@@ -147,7 +147,12 @@ export function getTransitionStatus(record) {
147
147
  }
148
148
 
149
149
  export async function transition(entityType, entityId, workflowName, toState, user, reason = '') {
150
- const record = await get(entityType, entityId);
150
+ // get(...,{user}) enforces the same row/org access scoping every other read
151
+ // path applies -- without it, a transition could read and act on a record
152
+ // outside the caller's org/row-access simply because this is a state-machine
153
+ // write rather than a plain field update, the exact bypass class bulk-ops'
154
+ // delete/set_field actions already close via the same call shape.
155
+ const record = await get(entityType, entityId, { user });
151
156
  if (!record) throw new AppError('Record not found', 'NOT_FOUND', HTTP.NOT_FOUND);
152
157
 
153
158
  validateTransition(workflowName, record.status || record.stage, toState, user);
@@ -127,6 +127,10 @@ export function createServer(options) {
127
127
  return await handleOAuthGoogleCallback(req, res);
128
128
  }
129
129
 
130
+ if (req.method === 'POST' && id && action === 'transition') {
131
+ return await handleEntityTransition(req, res, entity, id, thatcher, configEngine);
132
+ }
133
+
130
134
  if (req.method === 'POST' && id === 'import' && !action) {
131
135
  return await handleCsvImport(req, res, entity, thatcher, configEngine);
132
136
  }
@@ -378,22 +382,37 @@ async function handleGenericCrud(req, res, entity, id, action, thatcher, configE
378
382
  let result;
379
383
  let status = 200;
380
384
 
385
+ const { permissionService } = await import('../services/permission.service.js');
386
+
381
387
  switch (req.method) {
382
388
  case 'GET':
383
389
  if (id) {
384
- result = await thatcher.get(entity, id);
390
+ // get(...,{user}) applies the same row/org-access scoping every other
391
+ // read path in the codebase uses -- this generic-CRUD GET-by-id had
392
+ // none at all, so any authenticated user could read any record by id
393
+ // across organizations. filterFields then strips any field the
394
+ // caller's role isn't visible_to, the field-level half of this pass.
395
+ result = await thatcher.get(entity, id, { user });
385
396
  if (!result) {
386
397
  res.writeHead(404);
387
398
  res.end(JSON.stringify({ error: 'Not found' }));
388
399
  return;
389
400
  }
401
+ result = permissionService.filterFields(user, spec, result);
390
402
  } else {
391
403
  result = await thatcher.list(entity, {}, { user });
404
+ result = result.map(r => permissionService.filterFields(user, spec, r));
392
405
  }
393
406
  break;
394
407
 
395
408
  case 'POST':
409
+ // enforceEditPermissions is the authoritative field-level write check --
410
+ // it throws if the payload touches a field the caller's role isn't
411
+ // editable_by, so a hand-crafted request bypassing the rendered form
412
+ // cannot smuggle a restricted field in.
413
+ permissionService.enforceEditPermissions(user, spec, body);
396
414
  result = await thatcher.create(entity, body, user);
415
+ result = permissionService.filterFields(user, spec, result);
397
416
  status = 201;
398
417
  break;
399
418
 
@@ -404,7 +423,9 @@ async function handleGenericCrud(req, res, entity, id, action, thatcher, configE
404
423
  res.end(JSON.stringify({ error: 'ID required' }));
405
424
  return;
406
425
  }
426
+ permissionService.enforceEditPermissions(user, spec, body);
407
427
  result = await thatcher.update(entity, id, body, user);
428
+ result = permissionService.filterFields(user, spec, result);
408
429
  break;
409
430
 
410
431
  case 'DELETE':
@@ -506,6 +527,61 @@ async function handleBulkOperation(req, res, entityName, thatcher, configEngineA
506
527
  }
507
528
  }
508
529
 
530
+ async function handleEntityTransition(req, res, entityName, id, thatcher, configEngineArg) {
531
+ const user = await resolveRequestUser(req);
532
+ if (!user) {
533
+ res.writeHead(401, { 'Content-Type': 'application/json' });
534
+ res.end(JSON.stringify({ error: 'Authentication required' }));
535
+ return;
536
+ }
537
+
538
+ let configEngine = configEngineArg || thatcher?.configEngine || globalThis.__thatcherConfigEngine;
539
+ if (!configEngine) {
540
+ const { getConfigEngineSync } = await import('../lib/config-generator-engine.js');
541
+ configEngine = getConfigEngineSync();
542
+ }
543
+ let spec;
544
+ try {
545
+ spec = configEngine.generateEntitySpec(entityName);
546
+ } catch {
547
+ res.writeHead(404);
548
+ res.end(JSON.stringify({ error: `Entity "${entityName}" not found` }));
549
+ return;
550
+ }
551
+
552
+ let body;
553
+ try {
554
+ body = await readBody(req);
555
+ } catch (e) {
556
+ res.writeHead(400);
557
+ res.end(JSON.stringify({ error: e.message }));
558
+ return;
559
+ }
560
+ const workflowName = body?.workflow || spec.workflow;
561
+ const toState = body?.toState;
562
+ if (!workflowName || !toState) {
563
+ res.writeHead(400);
564
+ res.end(JSON.stringify({ error: 'workflow and toState required' }));
565
+ return;
566
+ }
567
+
568
+ try {
569
+ const { requirePermission } = await import('../lib/auth-middleware.js');
570
+ await requirePermission(user, spec, 'edit');
571
+ const { transition } = await import('../lib/workflow-engine.js');
572
+ // transition() itself now reads via get(...,{user}) -- the same
573
+ // row/org-access-scoped path every other read uses -- so a caller
574
+ // cannot drag-drop a record outside their access into a new stage.
575
+ const updated = await transition(entityName, id, workflowName, toState, user, body?.reason || '');
576
+ res.writeHead(200, { 'Content-Type': 'application/json' });
577
+ res.end(JSON.stringify({ ok: true, data: updated }));
578
+ } catch (err) {
579
+ apiLog.error(err.message);
580
+ res.writeHead(err.status || 400);
581
+ res.end(JSON.stringify({ error: err.message }));
582
+ }
583
+ }
584
+
509
585
  async function handleCreateEntityTemplate(req, res, thatcher, configEngineArg) {
510
586
  const user = await requireAuthedPartner(req, res);
511
587
  if (!user) return;
@@ -1,6 +1,7 @@
1
1
  import { page, dataTable } from '@/ui/layout.js'
2
2
  import { fmtVal, TOAST_SCRIPT, esc } from '@/ui/render-helpers.js'
3
3
  import { canCreate, canEdit, canDelete } from '@/ui/permissions-ui.js'
4
+ import { permissionService } from '@/services/permission.service.js'
4
5
 
5
6
  export function renderEntityList(entityName, items, spec, user, options = {}) {
6
7
  const label = spec?.labelPlural || spec?.label || entityName
@@ -85,7 +86,10 @@ function formatFieldValue(k, v, entityName, f) {
85
86
 
86
87
  export function renderEntityDetail(entityName, item, spec, user) {
87
88
  const label = spec?.label || entityName
88
- const fields = spec?.fields || {}
89
+ // A field the caller's role isn't visible_to must never reach rendered HTML,
90
+ // not just be CSS-hidden -- filterFields drops it from `fields` entirely so
91
+ // it can't appear in visibleFields below however this function evolves.
92
+ const fields = permissionService.filterFields(user, spec || {}, spec?.fields || {})
89
93
  const userCanEdit = canEdit(user, entityName)
90
94
  const userCanDelete = canDelete(user, entityName)
91
95
 
@@ -141,7 +145,15 @@ export function renderEntityDetail(entityName, item, spec, user) {
141
145
 
142
146
  export function renderEntityForm(entityName, item, spec, user, isNew = false, refOptions = {}, templates = []) {
143
147
  const label = spec?.label || entityName
144
- const fields = spec?.fields || {}
148
+ // A field the caller's role isn't editable_by must not be offered in the
149
+ // form at all -- omitting it here is UX (a clean form), the real
150
+ // enforcement is enforceEditPermissions rejecting the field server-side if
151
+ // a raw request includes it anyway.
152
+ const allFields = spec?.fields || {}
153
+ const fields = {}
154
+ for (const [k, f] of Object.entries(allFields)) {
155
+ if (permissionService.checkFieldAccess(user, spec || {}, k, 'edit')) fields[k] = f
156
+ }
145
157
  const lbl = (k, f, req) => `<label class="form-label" for="field-${k}">${esc(f.label||k)}${req ? '<span class="req">*</span>' : ''}</label>`
146
158
  const formFields = Object.entries(fields).filter(([k, f]) => k !== 'id' && !f.auto && !f.readOnly && !f.auto_generate && k !== 'password_hash').map(([k, f]) => {
147
159
  let val = item?.[k] ?? f.default ?? ''