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
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
|
-
|
|
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);
|
package/src/server/server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ?? ''
|