thatcher 1.0.78 → 1.0.80
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
|
@@ -196,7 +196,9 @@ export async function list(entity, where = {}, options = {}) {
|
|
|
196
196
|
const lim = options.limit ? parseInt(options.limit, 10) : rows.length;
|
|
197
197
|
rows = rows.slice(off, off + lim);
|
|
198
198
|
}
|
|
199
|
-
|
|
199
|
+
const { decryptFields } = await import('../field-encryption.js');
|
|
200
|
+
const decryptedRows = rows.map(r => decryptFields(r, spec.fields));
|
|
201
|
+
return attachRefDisplays(entity, decryptedRows);
|
|
200
202
|
}
|
|
201
203
|
|
|
202
204
|
export async function count(entity, where = {}, options = {}) {
|
|
@@ -227,7 +229,9 @@ export async function get(entity, id, options = {}) {
|
|
|
227
229
|
const { permissionService } = await import('../services/permission.service.js');
|
|
228
230
|
if (!permissionService.checkRowAccess(options.user, spec, row)) return null;
|
|
229
231
|
}
|
|
230
|
-
const
|
|
232
|
+
const { decryptFields } = await import('../field-encryption.js');
|
|
233
|
+
const decrypted = decryptFields(row, spec.fields);
|
|
234
|
+
const [withDisplay] = await attachRefDisplays(entity, [decrypted]);
|
|
231
235
|
return withDisplay;
|
|
232
236
|
}
|
|
233
237
|
|
|
@@ -255,15 +259,23 @@ export async function create(entity, data, user) {
|
|
|
255
259
|
if (field.auto === 'uuid' && !record[key]) record[key] = genId();
|
|
256
260
|
if (field.auto === 'timestamp' && !record[key]) record[key] = now();
|
|
257
261
|
}
|
|
262
|
+
// Encrypt any field marked encrypted:true BEFORE the null/undefined
|
|
263
|
+
// sentinel coercion below, so a real value never reaches the insert as
|
|
264
|
+
// plaintext -- this is the single choke point every create() call passes
|
|
265
|
+
// through, so no caller needs to know the field is encrypted at all.
|
|
266
|
+
const { encryptFields } = await import('../field-encryption.js');
|
|
267
|
+
const encryptedRecord = encryptFields(record, spec.fields);
|
|
258
268
|
// LanceDB cannot infer a column's type from a null on first insert; coerce any
|
|
259
269
|
// null/undefined to a typed sentinel ('' for strings) so the insert schema is stable.
|
|
260
|
-
for (const k of Object.keys(
|
|
261
|
-
if (
|
|
270
|
+
for (const k of Object.keys(encryptedRecord)) {
|
|
271
|
+
if (encryptedRecord[k] === null || encryptedRecord[k] === undefined) encryptedRecord[k] = '';
|
|
262
272
|
}
|
|
263
|
-
unwrap(await client().from(tbl).insert(
|
|
273
|
+
unwrap(await client().from(tbl).insert(encryptedRecord), 'create');
|
|
264
274
|
// Always return the locally-constructed record: it holds the genId we put in
|
|
265
275
|
// the TEXT id column. The store's insert() may return a rowid/driver shape, so
|
|
266
|
-
// trusting `created` would hand callers the wrong id.
|
|
276
|
+
// trusting `created` would hand callers the wrong id. Return the UNENCRYPTED
|
|
277
|
+
// record -- the caller gets back plaintext, matching what get()/list() will
|
|
278
|
+
// also return after decrypting the stored ciphertext.
|
|
267
279
|
return record;
|
|
268
280
|
}
|
|
269
281
|
|
|
@@ -284,14 +296,19 @@ export async function create(entity, data, user) {
|
|
|
284
296
|
// unconditional-write behaviour, unchanged for every existing caller; _version
|
|
285
297
|
// is added as a new column, ignored by every reader that doesn't ask for it.
|
|
286
298
|
export async function update(entity, id, data, opts = {}) {
|
|
287
|
-
specOf(entity);
|
|
299
|
+
const spec = specOf(entity);
|
|
288
300
|
const tbl = tableName(entity);
|
|
289
301
|
|
|
290
302
|
const existing = await get(entity, id);
|
|
291
303
|
if (!existing) throw new Error(`${entity} with id ${id} not found`);
|
|
292
304
|
|
|
293
305
|
const currentVersion = Number(existing._version) || 0;
|
|
294
|
-
const
|
|
306
|
+
const { encryptFields } = await import('../field-encryption.js');
|
|
307
|
+
// Encrypt only the fields actually present in this partial update -- a
|
|
308
|
+
// caller updating an unrelated field must not force-decrypt/re-encrypt an
|
|
309
|
+
// encrypted field it never touched (encryptFields already skips fields
|
|
310
|
+
// absent from the object, so this is naturally a no-op for those).
|
|
311
|
+
const patch = encryptFields({ ...data, updated_at: now(), _version: currentVersion + 1 }, spec.fields);
|
|
295
312
|
let builder = client().from(tbl).update(patch).eq('id', id);
|
|
296
313
|
if (opts.expectedVersion != null) {
|
|
297
314
|
builder = builder.eq('_version', opts.expectedVersion);
|
|
@@ -274,6 +274,84 @@ function withResourceManagementDefaults(masterConfig) {
|
|
|
274
274
|
return changed ? { ...masterConfig, entities } : masterConfig;
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
+
const CUSTOM_ENTITY_DEF_ENTITY_DEFAULT = {
|
|
278
|
+
label: 'Custom Entity Definition',
|
|
279
|
+
label_plural: 'Custom Entity Definitions',
|
|
280
|
+
system_entity: true,
|
|
281
|
+
fields: {
|
|
282
|
+
name: { type: 'text', required: true, unique: true, label: 'Name' },
|
|
283
|
+
label: { type: 'text', required: true, label: 'Label' },
|
|
284
|
+
label_plural: { type: 'text', label: 'Label (Plural)' },
|
|
285
|
+
fields: { type: 'json', required: true, label: 'Fields' },
|
|
286
|
+
owner_id: { type: 'ref', ref: 'user', label: 'Owner' },
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
function withCustomEntityDefaults(masterConfig) {
|
|
291
|
+
const entities = { ...(masterConfig.entities || {}) };
|
|
292
|
+
let changed = false;
|
|
293
|
+
if (!entities.custom_entity_def) { entities.custom_entity_def = CUSTOM_ENTITY_DEF_ENTITY_DEFAULT; changed = true; }
|
|
294
|
+
return changed ? { ...masterConfig, entities } : masterConfig;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Every field type this session has established, plus 'id' held reserved by
|
|
298
|
+
// itself since a custom entity spec is never allowed to declare it (the
|
|
299
|
+
// system fields loop in generateEntitySpec injects 'id' unconditionally).
|
|
300
|
+
const KNOWN_FIELD_TYPES = new Set([
|
|
301
|
+
'text', 'textarea', 'email', 'number', 'int', 'decimal', 'currency', 'date',
|
|
302
|
+
'timestamp', 'enum', 'bool', 'boolean', 'multiselect', 'ref', 'multiref',
|
|
303
|
+
'file', 'attachment', 'json',
|
|
304
|
+
]);
|
|
305
|
+
|
|
306
|
+
// A custom field key colliding with a reserved system field would let a
|
|
307
|
+
// crafted custom_entity_def silently override id/created_at/created_by/
|
|
308
|
+
// updated_at/status -- fields every entity's audit trail, sort defaults, and
|
|
309
|
+
// soft-delete logic assume are the framework's own. Rejected outright rather
|
|
310
|
+
// than allowing the collision and hoping the "declared field wins" merge
|
|
311
|
+
// order in generateEntitySpec never causes trouble.
|
|
312
|
+
const RESERVED_FIELD_KEYS = new Set(['id', 'created_at', 'created_by', 'updated_at', 'status']);
|
|
313
|
+
|
|
314
|
+
export function validateCustomEntityFields(fieldDefs) {
|
|
315
|
+
if (!Array.isArray(fieldDefs)) return { valid: false, error: 'fields must be an array' };
|
|
316
|
+
for (const f of fieldDefs) {
|
|
317
|
+
if (!f || typeof f.key !== 'string' || !f.key) return { valid: false, error: 'every field requires a key' };
|
|
318
|
+
if (RESERVED_FIELD_KEYS.has(f.key)) return { valid: false, error: `field key "${f.key}" collides with a reserved system field` };
|
|
319
|
+
if (!KNOWN_FIELD_TYPES.has(f.type)) return { valid: false, error: `unrecognized field type "${f.type}" for field "${f.key}"` };
|
|
320
|
+
}
|
|
321
|
+
return { valid: true };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function slugifyEntityName(name) {
|
|
325
|
+
return String(name).toLowerCase().trim().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Turns a custom_entity_def row's {key,type,label,required,options,ref}
|
|
329
|
+
// array into the SAME spec.fields[key] shape every code-registered system
|
|
330
|
+
// entity already uses, so generateEntitySpec's downstream logic (system
|
|
331
|
+
// field injection, permission_template resolution, workflow linkage) never
|
|
332
|
+
// has to know a given entity came from the database instead of source code.
|
|
333
|
+
export function customEntityDefToEntityDef(row) {
|
|
334
|
+
const fieldsArr = typeof row.fields === 'string' ? JSON.parse(row.fields) : row.fields;
|
|
335
|
+
const { valid, error } = validateCustomEntityFields(fieldsArr);
|
|
336
|
+
if (!valid) throw new Error(`[custom_entity_def "${row.name}"] ${error}`);
|
|
337
|
+
const fields = {};
|
|
338
|
+
for (const f of fieldsArr) {
|
|
339
|
+
fields[f.key] = {
|
|
340
|
+
type: f.type,
|
|
341
|
+
label: f.label || f.key,
|
|
342
|
+
required: !!f.required,
|
|
343
|
+
options: f.options || undefined,
|
|
344
|
+
ref: f.ref || undefined,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
return {
|
|
348
|
+
label: row.label || row.name,
|
|
349
|
+
label_plural: row.label_plural || row.label || row.name,
|
|
350
|
+
system_entity: false,
|
|
351
|
+
fields,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
277
355
|
const CONTRACT_LIFECYCLE_WORKFLOW = {
|
|
278
356
|
state_field: 'status',
|
|
279
357
|
stages: [
|
|
@@ -316,7 +394,7 @@ function withContractDefaults(masterConfig) {
|
|
|
316
394
|
export class ConfigGeneratorEngine {
|
|
317
395
|
constructor(masterConfig) {
|
|
318
396
|
if (!masterConfig) throw new Error('[ConfigGeneratorEngine] masterConfig is required');
|
|
319
|
-
this.masterConfig = deepFreeze(withResourceManagementDefaults(withContractDefaults(withTimeTrackingDefaults(withInventoryDefaults(withProjectDefaults(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig)))))))));
|
|
397
|
+
this.masterConfig = deepFreeze(withCustomEntityDefaults(withResourceManagementDefaults(withContractDefaults(withTimeTrackingDefaults(withInventoryDefaults(withProjectDefaults(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig))))))))));
|
|
320
398
|
this.specCache = new LRUCache(100);
|
|
321
399
|
this.debugMode = false;
|
|
322
400
|
this._plugins = new Map();
|
|
@@ -346,6 +424,26 @@ export class ConfigGeneratorEngine {
|
|
|
346
424
|
return this;
|
|
347
425
|
}
|
|
348
426
|
|
|
427
|
+
// Merges one custom_entity_def row into masterConfig.entities, keyed by a
|
|
428
|
+
// slugified version of its name -- after this call, generateEntitySpec(slug)
|
|
429
|
+
// returns a real usable spec and every generic CRUD/view/report route that
|
|
430
|
+
// reads config.entities works for it with zero entity-specific code, the
|
|
431
|
+
// same reuse discipline every code-registered system entity already gets.
|
|
432
|
+
// A slug colliding with an EXISTING entity (system or another custom one)
|
|
433
|
+
// is rejected rather than silently overwriting it.
|
|
434
|
+
registerCustomEntity(row) {
|
|
435
|
+
const slug = slugifyEntityName(row.name);
|
|
436
|
+
if (!slug) throw new Error('[ConfigGeneratorEngine] registerCustomEntity: name produces an empty slug');
|
|
437
|
+
if (this.masterConfig.entities?.[slug] && !this.masterConfig.entities[slug]._fromCustomEntityDef) {
|
|
438
|
+
throw new Error(`[ConfigGeneratorEngine] registerCustomEntity: "${slug}" collides with an existing entity`);
|
|
439
|
+
}
|
|
440
|
+
const entityDef = { ...customEntityDefToEntityDef(row), _fromCustomEntityDef: true };
|
|
441
|
+
const nextEntities = { ...(this.masterConfig.entities || {}), [slug]: entityDef };
|
|
442
|
+
this.masterConfig = deepFreeze({ ...this.masterConfig, entities: nextEntities });
|
|
443
|
+
this.specCache.clear();
|
|
444
|
+
return slug;
|
|
445
|
+
}
|
|
446
|
+
|
|
349
447
|
registerPlugin(entityName, plugin = {}) {
|
|
350
448
|
if (!entityName || typeof entityName !== 'string') {
|
|
351
449
|
throw new Error('[ConfigGeneratorEngine] registerPlugin: entityName required');
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
|
|
3
|
+
// AES-256-GCM: authenticated encryption, so a tampered ciphertext fails
|
|
4
|
+
// decryption loudly instead of silently returning corrupted plaintext. A
|
|
5
|
+
// fresh random IV per value (never reused) is required for GCM's security
|
|
6
|
+
// guarantee -- reusing an IV with the same key breaks confidentiality.
|
|
7
|
+
const ALGORITHM = 'aes-256-gcm';
|
|
8
|
+
const IV_LENGTH = 12;
|
|
9
|
+
const ENCRYPTED_PREFIX = 'enc:v1:';
|
|
10
|
+
|
|
11
|
+
let _key = null;
|
|
12
|
+
function getKey() {
|
|
13
|
+
if (_key) return _key;
|
|
14
|
+
const raw = process.env.FIELD_ENCRYPTION_KEY;
|
|
15
|
+
if (!raw) {
|
|
16
|
+
// Fail loud, never silently store plaintext under an encrypted:true field
|
|
17
|
+
// just because the operator forgot to configure a key.
|
|
18
|
+
throw new Error('FIELD_ENCRYPTION_KEY environment variable is not set; cannot encrypt/decrypt a field marked encrypted:true');
|
|
19
|
+
}
|
|
20
|
+
// Accept either a 64-char hex string or any string, hashed to a stable
|
|
21
|
+
// 32-byte key -- never echoed, never logged, this function's return value
|
|
22
|
+
// is the only place the raw key material appears in memory.
|
|
23
|
+
const key = /^[0-9a-fA-F]{64}$/.test(raw) ? Buffer.from(raw, 'hex') : crypto.createHash('sha256').update(raw).digest();
|
|
24
|
+
_key = key;
|
|
25
|
+
return _key;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function encryptValue(plaintext) {
|
|
29
|
+
const key = getKey();
|
|
30
|
+
const iv = crypto.randomBytes(IV_LENGTH);
|
|
31
|
+
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
|
32
|
+
const serialized = typeof plaintext === 'string' ? plaintext : JSON.stringify(plaintext);
|
|
33
|
+
const ciphertext = Buffer.concat([cipher.update(serialized, 'utf8'), cipher.final()]);
|
|
34
|
+
const authTag = cipher.getAuthTag();
|
|
35
|
+
return ENCRYPTED_PREFIX + Buffer.concat([iv, authTag, ciphertext]).toString('base64');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isEncrypted(value) {
|
|
39
|
+
return typeof value === 'string' && value.startsWith(ENCRYPTED_PREFIX);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function decryptValue(stored) {
|
|
43
|
+
if (!isEncrypted(stored)) return stored;
|
|
44
|
+
const key = getKey();
|
|
45
|
+
const raw = Buffer.from(stored.slice(ENCRYPTED_PREFIX.length), 'base64');
|
|
46
|
+
const iv = raw.subarray(0, IV_LENGTH);
|
|
47
|
+
const authTag = raw.subarray(IV_LENGTH, IV_LENGTH + 16);
|
|
48
|
+
const ciphertext = raw.subarray(IV_LENGTH + 16);
|
|
49
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
|
50
|
+
decipher.setAuthTag(authTag);
|
|
51
|
+
// setAuthTag + final() throws on any tampering (wrong key, flipped bytes,
|
|
52
|
+
// truncated ciphertext) -- this is the loud-failure guarantee; there is no
|
|
53
|
+
// catch here, a decrypt failure must propagate to the caller as an error.
|
|
54
|
+
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(decrypted);
|
|
57
|
+
} catch {
|
|
58
|
+
return decrypted;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Field-def-driven encrypt of every field marked encrypted:true. Fields not
|
|
63
|
+
// present in `record` or not marked are passed through unchanged.
|
|
64
|
+
export function encryptFields(record, specFields) {
|
|
65
|
+
if (!specFields) return record;
|
|
66
|
+
const result = { ...record };
|
|
67
|
+
for (const [key, fieldDef] of Object.entries(specFields)) {
|
|
68
|
+
if (!fieldDef.encrypted) continue;
|
|
69
|
+
if (result[key] === undefined || result[key] === null || result[key] === '') continue;
|
|
70
|
+
if (isEncrypted(result[key])) continue; // already encrypted, don't double-wrap
|
|
71
|
+
result[key] = encryptValue(result[key]);
|
|
72
|
+
}
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function decryptFields(record, specFields) {
|
|
77
|
+
if (!record || !specFields) return record;
|
|
78
|
+
const result = { ...record };
|
|
79
|
+
for (const [key, fieldDef] of Object.entries(specFields)) {
|
|
80
|
+
if (!fieldDef.encrypted) continue;
|
|
81
|
+
if (result[key] === undefined || result[key] === null) continue;
|
|
82
|
+
result[key] = decryptValue(result[key]);
|
|
83
|
+
}
|
|
84
|
+
return result;
|
|
85
|
+
}
|
package/src/server/server.js
CHANGED
|
@@ -83,6 +83,22 @@ export function createServer(options) {
|
|
|
83
83
|
if (!systemInitialized) {
|
|
84
84
|
const { loadPlugins } = await load(path.join(__dirname, '../plugins/index.js'));
|
|
85
85
|
await loadPlugins(configEngine);
|
|
86
|
+
// Custom entities are DB rows, not code -- they can only be loaded
|
|
87
|
+
// once both the config engine AND the store are available, which is
|
|
88
|
+
// exactly this first-request boundary (the engine itself must be
|
|
89
|
+
// constructible with no DB dependency, per its existing contract).
|
|
90
|
+
// A bad row must not crash server startup for every other entity, so
|
|
91
|
+
// registration failures are logged and skipped individually.
|
|
92
|
+
try {
|
|
93
|
+
const { list } = await import('../lib/busybase/store.js');
|
|
94
|
+
const customDefs = await list('custom_entity_def', {});
|
|
95
|
+
for (const row of customDefs) {
|
|
96
|
+
try { configEngine.registerCustomEntity(row); }
|
|
97
|
+
catch (e) { log.error(`Failed to register custom entity "${row.name}": ${e.message}`); }
|
|
98
|
+
}
|
|
99
|
+
} catch (e) {
|
|
100
|
+
log.error(`Failed to load custom_entity_def rows: ${e.message}`);
|
|
101
|
+
}
|
|
86
102
|
systemInitialized = true;
|
|
87
103
|
log.info('System ready');
|
|
88
104
|
}
|
|
@@ -191,6 +207,14 @@ export function createServer(options) {
|
|
|
191
207
|
return await handleDeleteEntityTemplate(req, res, id, thatcher, configEngine);
|
|
192
208
|
}
|
|
193
209
|
|
|
210
|
+
if (req.method === 'POST' && entity === 'custom_entity_def' && id === 'create' && !action) {
|
|
211
|
+
return await handleCreateCustomEntityDef(req, res, thatcher, configEngine);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (req.method === 'POST' && entity === 'custom_entity_def' && id && action === 'delete') {
|
|
215
|
+
return await handleDeleteCustomEntityDef(req, res, id, thatcher, configEngine);
|
|
216
|
+
}
|
|
217
|
+
|
|
194
218
|
if (req.method === 'POST' && entity === 'upload' && !id) {
|
|
195
219
|
return await handleFileUpload(req, res, thatcher, configEngine);
|
|
196
220
|
}
|
|
@@ -719,6 +743,92 @@ async function handleDeleteEntityTemplate(req, res, templateId, thatcher, config
|
|
|
719
743
|
}
|
|
720
744
|
}
|
|
721
745
|
|
|
746
|
+
async function handleCreateCustomEntityDef(req, res, thatcher, configEngineArg) {
|
|
747
|
+
const user = await requireAuthedPartner(req, res);
|
|
748
|
+
if (!user) return;
|
|
749
|
+
|
|
750
|
+
let configEngine = configEngineArg || thatcher?.configEngine || globalThis.__thatcherConfigEngine;
|
|
751
|
+
if (!configEngine) {
|
|
752
|
+
const { getConfigEngineSync } = await import('../lib/config-generator-engine.js');
|
|
753
|
+
configEngine = getConfigEngineSync();
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
let body;
|
|
757
|
+
try {
|
|
758
|
+
body = await readBody(req);
|
|
759
|
+
} catch (e) {
|
|
760
|
+
res.writeHead(400);
|
|
761
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
const { name, label, label_plural, fields } = body || {};
|
|
765
|
+
if (!name || typeof name !== 'string' || !name.trim()) {
|
|
766
|
+
res.writeHead(400);
|
|
767
|
+
res.end(JSON.stringify({ error: 'name required' }));
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
if (!label || typeof label !== 'string' || !label.trim()) {
|
|
771
|
+
res.writeHead(400);
|
|
772
|
+
res.end(JSON.stringify({ error: 'label required' }));
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const { validateCustomEntityFields } = await import('../lib/config-generator-engine.js');
|
|
777
|
+
// Same allow-listed field-type vocabulary and reserved-key rejection every
|
|
778
|
+
// custom entity gets, checked here BEFORE the record is ever persisted --
|
|
779
|
+
// a bad definition must never reach the store, let alone registerCustomEntity.
|
|
780
|
+
const fieldValidation = validateCustomEntityFields(fields);
|
|
781
|
+
if (!fieldValidation.valid) {
|
|
782
|
+
res.writeHead(400);
|
|
783
|
+
res.end(JSON.stringify({ error: fieldValidation.error }));
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
try {
|
|
788
|
+
const record = { name: name.trim(), label: label.trim(), label_plural: (label_plural || '').trim() || undefined, fields, owner_id: user.id };
|
|
789
|
+
// Register FIRST -- if the slug collides with an existing entity or the
|
|
790
|
+
// shape is otherwise invalid at the engine level, the definition is
|
|
791
|
+
// never persisted, so a rejected custom_entity_def never lingers as a
|
|
792
|
+
// dead row claiming a slug it was never actually allowed to use.
|
|
793
|
+
const slug = configEngine.registerCustomEntity(record);
|
|
794
|
+
const { create } = await import('../lib/busybase/store.js');
|
|
795
|
+
const created = await create('custom_entity_def', record, user);
|
|
796
|
+
res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
797
|
+
res.end(JSON.stringify({ ok: true, id: created.id, slug }));
|
|
798
|
+
} catch (err) {
|
|
799
|
+
apiLog.error(err.message);
|
|
800
|
+
res.writeHead(400);
|
|
801
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
async function handleDeleteCustomEntityDef(req, res, defId, thatcher, configEngineArg) {
|
|
806
|
+
const user = await requireAuthedPartner(req, res);
|
|
807
|
+
if (!user) return;
|
|
808
|
+
|
|
809
|
+
try {
|
|
810
|
+
const { remove, get } = await import('../lib/busybase/store.js');
|
|
811
|
+
const existing = await get('custom_entity_def', defId);
|
|
812
|
+
if (!existing) {
|
|
813
|
+
res.writeHead(404);
|
|
814
|
+
res.end(JSON.stringify({ error: 'Custom entity definition not found' }));
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
await remove('custom_entity_def', defId);
|
|
818
|
+
// Deliberately does NOT unregister the entity from the running config
|
|
819
|
+
// engine -- existing data for that entity must remain readable this
|
|
820
|
+
// session; removal only takes full effect on next restart's fresh
|
|
821
|
+
// custom_entity_def load, the same "config changes need a reload"
|
|
822
|
+
// contract updateWorkflow/updatePermissionTemplate already carry.
|
|
823
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
824
|
+
res.end(JSON.stringify({ ok: true }));
|
|
825
|
+
} catch (err) {
|
|
826
|
+
apiLog.error(err.message);
|
|
827
|
+
res.writeHead(500);
|
|
828
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
722
832
|
async function handleCreateScheduledJob(req, res, thatcher, configEngineArg) {
|
|
723
833
|
const user = await requireAuthedPartner(req, res);
|
|
724
834
|
if (!user) return;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { page } from '@/ui/layout.js';
|
|
2
|
+
import { esc } from '@/ui/render-helpers.js';
|
|
3
|
+
|
|
4
|
+
const FIELD_TYPES = ['text', 'textarea', 'email', 'number', 'currency', 'date', 'enum', 'bool', 'multiselect', 'ref', 'multiref', 'file', 'json'];
|
|
5
|
+
|
|
6
|
+
export function renderCustomEntityList(user, defs) {
|
|
7
|
+
const rows = defs.map(d => {
|
|
8
|
+
const fieldsArr = typeof d.fields === 'string' ? JSON.parse(d.fields || '[]') : (d.fields || []);
|
|
9
|
+
const fieldSummary = fieldsArr.map(f => `${f.key}:${f.type}`).join(', ');
|
|
10
|
+
return `<tr>
|
|
11
|
+
<td>${esc(d.name)}</td>
|
|
12
|
+
<td>${esc(d.label)}</td>
|
|
13
|
+
<td style="font-size:12px">${esc(fieldSummary)}</td>
|
|
14
|
+
<td><a href="/${esc(d.name.toLowerCase().replace(/[^a-z0-9]+/g,'_'))}" class="btn-ghost-clean">View</a>
|
|
15
|
+
<button type="button" class="btn-ghost-clean" data-action="deleteCustomEntity" data-args='["${esc(d.id)}"]'>Delete</button>
|
|
16
|
+
</td>
|
|
17
|
+
</tr>`;
|
|
18
|
+
}).join('') || '<tr><td colspan="4">No custom entities</td></tr>';
|
|
19
|
+
|
|
20
|
+
const typeOpts = FIELD_TYPES.map(t => `<option value="${esc(t)}">${esc(t)}</option>`).join('');
|
|
21
|
+
|
|
22
|
+
const content = `<div class="page-header"><h1 class="page-title">Custom Entities</h1></div>
|
|
23
|
+
<p style="font-size:13px;color:var(--color-text-muted,#666);margin-bottom:16px">Define a brand-new entity at runtime -- once created, it gets the full generic list/detail/form/board/report views and API every built-in entity has, with zero code.</p>
|
|
24
|
+
<div class="card-clean" style="margin-bottom:16px"><div class="card-clean-body">
|
|
25
|
+
<table class="data-table"><thead><tr><th>Name</th><th>Label</th><th>Fields</th><th></th></tr></thead><tbody>${rows}</tbody></table>
|
|
26
|
+
</div></div>
|
|
27
|
+
<div class="card-clean"><div class="card-clean-body">
|
|
28
|
+
<h3 style="margin-bottom:8px">New Custom Entity</h3>
|
|
29
|
+
<div style="display:flex;flex-direction:column;gap:8px;max-width:560px">
|
|
30
|
+
<input type="text" id="new-entity-name" placeholder="Internal name, e.g. equipment" class="form-input">
|
|
31
|
+
<input type="text" id="new-entity-label" placeholder="Label, e.g. Equipment" class="form-input">
|
|
32
|
+
<input type="text" id="new-entity-label-plural" placeholder="Plural label, e.g. Equipment Items" class="form-input">
|
|
33
|
+
<div id="field-rows"></div>
|
|
34
|
+
<button type="button" class="btn-ghost-clean" data-action="addFieldRow">+ Add Field</button>
|
|
35
|
+
<button type="button" class="btn-primary-clean" data-action="createCustomEntity">Create Entity</button>
|
|
36
|
+
</div>
|
|
37
|
+
</div></div>
|
|
38
|
+
<span id="custom-entity-status" style="font-size:13px"></span>`;
|
|
39
|
+
|
|
40
|
+
const script = `(function(){
|
|
41
|
+
var fieldRowCount=0;
|
|
42
|
+
function addFieldRow(){
|
|
43
|
+
var container=document.getElementById('field-rows');
|
|
44
|
+
var idx=fieldRowCount++;
|
|
45
|
+
var row=document.createElement('div');
|
|
46
|
+
row.className='field-row';
|
|
47
|
+
row.style.cssText='display:flex;gap:6px;margin:4px 0';
|
|
48
|
+
row.innerHTML='<input type="text" class="form-input field-key" placeholder="key" style="flex:1">'+
|
|
49
|
+
'<select class="form-input field-type" style="flex:1">${typeOpts}</select>'+
|
|
50
|
+
'<input type="text" class="form-input field-label" placeholder="label" style="flex:1">'+
|
|
51
|
+
'<label style="display:flex;align-items:center;gap:4px;font-size:12px"><input type="checkbox" class="field-required">required</label>'+
|
|
52
|
+
'<button type="button" class="btn-ghost-clean" data-action="removeFieldRow">x</button>';
|
|
53
|
+
container.appendChild(row);
|
|
54
|
+
}
|
|
55
|
+
window.addFieldRow=addFieldRow;
|
|
56
|
+
window.removeFieldRow=function(btn){ if(btn&&btn.closest)btn.closest('.field-row').remove() };
|
|
57
|
+
addFieldRow();
|
|
58
|
+
|
|
59
|
+
window.createCustomEntity=function(){
|
|
60
|
+
var status=document.getElementById('custom-entity-status');
|
|
61
|
+
var name=(document.getElementById('new-entity-name').value||'').trim();
|
|
62
|
+
var label=(document.getElementById('new-entity-label').value||'').trim();
|
|
63
|
+
var labelPlural=(document.getElementById('new-entity-label-plural').value||'').trim();
|
|
64
|
+
if(!name){status.textContent='Name required';return}
|
|
65
|
+
if(!label){status.textContent='Label required';return}
|
|
66
|
+
var fields=[];
|
|
67
|
+
document.querySelectorAll('.field-row').forEach(function(row){
|
|
68
|
+
var key=row.querySelector('.field-key').value.trim();
|
|
69
|
+
var type=row.querySelector('.field-type').value;
|
|
70
|
+
var flabel=row.querySelector('.field-label').value.trim();
|
|
71
|
+
var required=row.querySelector('.field-required').checked;
|
|
72
|
+
if(key)fields.push({key:key,type:type,label:flabel||key,required:required});
|
|
73
|
+
});
|
|
74
|
+
if(!fields.length){status.textContent='At least one field required';return}
|
|
75
|
+
status.textContent='Creating...';
|
|
76
|
+
fetch('/api/custom_entity_def/create',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:name,label:label,label_plural:labelPlural,fields:fields})})
|
|
77
|
+
.then(function(r){return r.json().then(function(d){return {ok:r.ok,d:d}})})
|
|
78
|
+
.then(function(res){if(res.ok){location.reload()}else{status.textContent='Error: '+(res.d.error||'create failed')}})
|
|
79
|
+
.catch(function(err){status.textContent='Error: '+err.message});
|
|
80
|
+
};
|
|
81
|
+
window.deleteCustomEntity=function(id){
|
|
82
|
+
var status=document.getElementById('custom-entity-status');
|
|
83
|
+
status.textContent='Deleting...';
|
|
84
|
+
fetch('/api/custom_entity_def/'+id+'/delete',{method:'POST'})
|
|
85
|
+
.then(function(r){return r.json().then(function(d){return {ok:r.ok,d:d}})})
|
|
86
|
+
.then(function(res){if(res.ok){location.reload()}else{status.textContent='Error: '+(res.d.error||'delete failed')}})
|
|
87
|
+
.catch(function(err){status.textContent='Error: '+err.message});
|
|
88
|
+
};
|
|
89
|
+
})();`;
|
|
90
|
+
|
|
91
|
+
return page(user, 'Custom Entities | Thatcher', [{ href: '/admin/settings', label: 'Settings' }, { label: 'Custom Entities' }], content, [script]);
|
|
92
|
+
}
|
|
@@ -11,6 +11,7 @@ import { renderRolesList, renderTemplateList, renderPermissionMatrix } from '@/u
|
|
|
11
11
|
import { renderWebhookList, renderWebhookDetail } from '@/ui/webhook-renderer.js';
|
|
12
12
|
import { renderTemplateList as renderEntityTemplateList } from '@/ui/template-renderer.js';
|
|
13
13
|
import { renderScheduledJobList } from '@/ui/scheduled-job-renderer.js';
|
|
14
|
+
import { renderCustomEntityList } from '@/ui/custom-entity-renderer.js';
|
|
14
15
|
import { isPartner, isManager } from '@/ui/permissions-ui.js';
|
|
15
16
|
import { getSystemConfig, getSettingsCounts, getAuditData, getSystemHealth, renderBuildLogsContent } from '@/ui/page-handler-helpers.js';
|
|
16
17
|
import { fileURLToPath } from 'url';
|
|
@@ -206,5 +207,10 @@ export async function handleAdminPage(normalized, segments, user, req) {
|
|
|
206
207
|
let jobs = []; try { jobs = await list('scheduled_job', {}); } catch {}
|
|
207
208
|
return renderScheduledJobList(user, jobs, entityNames);
|
|
208
209
|
}
|
|
210
|
+
if (normalized === '/admin/custom-entities') {
|
|
211
|
+
if (!isPartner(user)) return renderAccessDenied(user, 'admin', 'view');
|
|
212
|
+
let defs = []; try { defs = await list('custom_entity_def', {}); } catch {}
|
|
213
|
+
return renderCustomEntityList(user, defs);
|
|
214
|
+
}
|
|
209
215
|
return null;
|
|
210
216
|
}
|