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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thatcher",
3
- "version": "1.0.78",
3
+ "version": "1.0.80",
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",
@@ -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
- return attachRefDisplays(entity, rows);
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 [withDisplay] = await attachRefDisplays(entity, [row]);
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(record)) {
261
- if (record[k] === null || record[k] === undefined) record[k] = '';
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(record), 'create');
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 patch = { ...data, updated_at: now(), _version: currentVersion + 1 };
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
+ }
@@ -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
  }