thatcher 1.0.55 → 1.0.56

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.55",
3
+ "version": "1.0.56",
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",
@@ -162,12 +162,13 @@ export async function list(entity, where = {}, options = {}) {
162
162
  rows = applyVisibility(spec, rows, where, options);
163
163
 
164
164
  // Row-access scoping: when a caller passes options.user AND the entity declares
165
- // rowAccess, restrict the rows to what that user may see (their assigned cases,
166
- // their team, etc.). Opt-in -- no user means the read is unchanged, so internal
167
- // and admin callers are unaffected. This makes a config row_access spec actually
168
- // enforced on the read path (previously list() took no user, so the spec was
169
- // inert and a scoped enquiry would leak every row).
170
- if (options.user && (spec.rowAccess || spec.row_access)) {
165
+ // rowAccess OR an organization_id field (multi-tenancy), restrict the rows to
166
+ // what that user may see (their assigned cases, their team, their organization).
167
+ // Opt-in -- no user means the read is unchanged, so internal and admin callers
168
+ // are unaffected. This makes a config row_access spec actually enforced on the
169
+ // read path (previously list() took no user, so the spec was inert and a scoped
170
+ // enquiry would leak every row).
171
+ if (options.user && (spec.rowAccess || spec.row_access || spec.fields?.organization_id)) {
171
172
  const { permissionService } = await import('../services/permission.service.js');
172
173
  rows = permissionService.filterRecords(options.user, spec, rows);
173
174
  }
@@ -203,7 +204,7 @@ export async function count(entity, where = {}, options = {}) {
203
204
  const tbl = tableName(entity);
204
205
  let rows = unwrap(await applyWhere(client().from(tbl).select('*'), where), 'count');
205
206
  rows = applyVisibility(spec, rows, where, options);
206
- if (options.user && (spec.rowAccess || spec.row_access)) {
207
+ if (options.user && (spec.rowAccess || spec.row_access || spec.fields?.organization_id)) {
207
208
  const { permissionService } = await import('../services/permission.service.js');
208
209
  rows = permissionService.filterRecords(options.user, spec, rows);
209
210
  }
@@ -217,10 +218,15 @@ export async function listWithPagination(entity, where = {}, page = 1, pageSize
217
218
  return { items, pagination: { page: finalPage, pageSize, total, totalPages: Math.ceil(total / pageSize) } };
218
219
  }
219
220
 
220
- export async function get(entity, id) {
221
+ export async function get(entity, id, options = {}) {
221
222
  const tbl = tableName(entity);
222
223
  const row = unwrap(await client().from(tbl).select('*').eq('id', id).maybeSingle(), 'get');
223
224
  if (!row) return null;
225
+ const spec = specOf(entity);
226
+ if (options.user && (spec.rowAccess || spec.row_access || spec.fields?.organization_id)) {
227
+ const { permissionService } = await import('../services/permission.service.js');
228
+ if (!permissionService.checkRowAccess(options.user, spec, row)) return null;
229
+ }
224
230
  const [withDisplay] = await attachRefDisplays(entity, [row]);
225
231
  return withDisplay;
226
232
  }
@@ -242,6 +248,9 @@ export async function create(entity, data, user) {
242
248
  updated_at: 0,
243
249
  status: data.status || RECORD_STATUS.ACTIVE,
244
250
  };
251
+ if (spec.fields?.organization_id && !record.organization_id && user?.organization_id) {
252
+ record.organization_id = user.organization_id;
253
+ }
245
254
  for (const [key, field] of Object.entries(spec.fields || {})) {
246
255
  if (field.auto === 'uuid' && !record[key]) record[key] = genId();
247
256
  if (field.auto === 'timestamp' && !record[key]) record[key] = now();
@@ -14,15 +14,46 @@
14
14
  // central infrastructure, not a candidate for deprecation.
15
15
  import { LRUCache, deepFreeze, deepClone, recursiveResolve } from './config-helpers.js';
16
16
 
17
+ const ORGANIZATION_ENTITY_DEFAULT = {
18
+ label: 'Organization',
19
+ label_plural: 'Organizations',
20
+ system_entity: true,
21
+ fields: {
22
+ name: { type: 'text', required: true, label: 'Name' },
23
+ },
24
+ };
25
+
26
+ const USER_ORGANIZATION_ENTITY_DEFAULT = {
27
+ label: 'Organization Membership',
28
+ label_plural: 'Organization Memberships',
29
+ system_entity: true,
30
+ fields: {
31
+ user_id: { type: 'ref', ref: 'user', required: true },
32
+ organization_id: { type: 'ref', ref: 'organization', required: true },
33
+ },
34
+ };
35
+
36
+ function withMultiTenancyDefaults(masterConfig) {
37
+ if (!masterConfig?.system?.multi_tenancy?.enabled) return masterConfig;
38
+ const entities = { ...(masterConfig.entities || {}) };
39
+ if (!entities.organization) entities.organization = ORGANIZATION_ENTITY_DEFAULT;
40
+ if (!entities.user_organization) entities.user_organization = USER_ORGANIZATION_ENTITY_DEFAULT;
41
+ return { ...masterConfig, entities };
42
+ }
43
+
17
44
  export class ConfigGeneratorEngine {
18
45
  constructor(masterConfig) {
19
46
  if (!masterConfig) throw new Error('[ConfigGeneratorEngine] masterConfig is required');
20
- this.masterConfig = deepFreeze(masterConfig);
47
+ this.masterConfig = deepFreeze(withMultiTenancyDefaults(masterConfig));
21
48
  this.specCache = new LRUCache(100);
22
49
  this.debugMode = false;
23
50
  this._plugins = new Map();
24
51
  }
25
52
 
53
+ isMultiTenancyEnabled() {
54
+ return this.masterConfig?.system?.multi_tenancy?.enabled === true;
55
+ }
56
+
26
57
  registerPlugin(entityName, plugin = {}) {
27
58
  if (!entityName || typeof entityName !== 'string') {
28
59
  throw new Error('[ConfigGeneratorEngine] registerPlugin: entityName required');
@@ -315,6 +346,9 @@ export class ConfigGeneratorEngine {
315
346
  updated_at: { type: 'timestamp', readonly: true },
316
347
  status: { type: 'text' },
317
348
  };
349
+ if (this.isMultiTenancyEnabled() && !spec.embedded && !spec.system_entity) {
350
+ SYSTEM_FIELDS.organization_id = { type: 'ref', ref: 'organization', required: true, readonly: true, hidden: true };
351
+ }
318
352
  for (const [key, field] of Object.entries(SYSTEM_FIELDS)) {
319
353
  if (!(key in allFields)) allFields[key] = field;
320
354
  }
@@ -106,6 +106,14 @@ export function createServer(options) {
106
106
  return await handleCsvImport(req, res, entity, thatcher, configEngine);
107
107
  }
108
108
 
109
+ if (req.method === 'POST' && entity === 'organization' && id === 'switch' && !action) {
110
+ return await handleSwitchOrganization(req, res, thatcher, configEngine);
111
+ }
112
+
113
+ if (req.method === 'GET' && entity === 'organization' && id === 'memberships' && !action) {
114
+ return await handleListMemberships(req, res, thatcher, configEngine);
115
+ }
116
+
109
117
  // Check if user has custom route for this
110
118
  const userRoutePath = path.join(process.cwd(), 'app/api', ...parts, 'route.js');
111
119
  const routeExists = await fileExists(userRoutePath);
@@ -333,6 +341,95 @@ async function handleGenericCrud(req, res, entity, id, action, thatcher, configE
333
341
  }
334
342
  }
335
343
 
344
+ async function handleListMemberships(req, res, thatcher, configEngineArg) {
345
+ const user = await resolveRequestUser(req);
346
+ if (!user) {
347
+ res.writeHead(401, { 'Content-Type': 'application/json' });
348
+ res.end(JSON.stringify({ error: 'Authentication required' }));
349
+ return;
350
+ }
351
+
352
+ let configEngine = configEngineArg || thatcher?.configEngine || globalThis.__thatcherConfigEngine;
353
+ if (!configEngine) {
354
+ const { getConfigEngineSync } = await import('../lib/config-generator-engine.js');
355
+ configEngine = getConfigEngineSync();
356
+ }
357
+ if (!configEngine.isMultiTenancyEnabled?.()) {
358
+ res.writeHead(404);
359
+ res.end(JSON.stringify({ error: 'Multi-tenancy is not enabled' }));
360
+ return;
361
+ }
362
+
363
+ try {
364
+ const { list, get } = await import('../lib/busybase/store.js');
365
+ const memberships = await list('user_organization', { user_id: user.id });
366
+ const orgs = [];
367
+ for (const m of memberships) {
368
+ const org = await get('organization', m.organization_id);
369
+ if (org) orgs.push({ id: org.id, name: org.name });
370
+ }
371
+ res.writeHead(200, { 'Content-Type': 'application/json' });
372
+ res.end(JSON.stringify({ organizations: orgs, active: user.organization_id || null }));
373
+ } catch (err) {
374
+ apiLog.error(err.message);
375
+ res.writeHead(500);
376
+ res.end(JSON.stringify({ error: err.message }));
377
+ }
378
+ }
379
+
380
+ async function handleSwitchOrganization(req, res, thatcher, configEngineArg) {
381
+ const user = await resolveRequestUser(req);
382
+ if (!user) {
383
+ res.writeHead(401, { 'Content-Type': 'application/json' });
384
+ res.end(JSON.stringify({ error: 'Authentication required' }));
385
+ return;
386
+ }
387
+
388
+ let configEngine = configEngineArg || thatcher?.configEngine || globalThis.__thatcherConfigEngine;
389
+ if (!configEngine) {
390
+ const { getConfigEngineSync } = await import('../lib/config-generator-engine.js');
391
+ configEngine = getConfigEngineSync();
392
+ }
393
+ if (!configEngine.isMultiTenancyEnabled?.()) {
394
+ res.writeHead(404);
395
+ res.end(JSON.stringify({ error: 'Multi-tenancy is not enabled' }));
396
+ return;
397
+ }
398
+
399
+ let body;
400
+ try {
401
+ body = await readBody(req);
402
+ } catch (e) {
403
+ res.writeHead(400);
404
+ res.end(JSON.stringify({ error: e.message }));
405
+ return;
406
+ }
407
+ const targetOrgId = typeof body === 'object' ? body?.organization_id : null;
408
+ if (!targetOrgId) {
409
+ res.writeHead(400);
410
+ res.end(JSON.stringify({ error: 'organization_id required' }));
411
+ return;
412
+ }
413
+
414
+ try {
415
+ const { list, update } = await import('../lib/busybase/store.js');
416
+ const memberships = await list('user_organization', { user_id: user.id });
417
+ const isMember = memberships.some(m => m.organization_id === targetOrgId);
418
+ if (!isMember) {
419
+ res.writeHead(403);
420
+ res.end(JSON.stringify({ error: 'Not a member of that organization' }));
421
+ return;
422
+ }
423
+ const updated = await update('user', user.id, { organization_id: targetOrgId });
424
+ res.writeHead(200, { 'Content-Type': 'application/json' });
425
+ res.end(JSON.stringify({ ok: true, organization_id: updated.organization_id }));
426
+ } catch (err) {
427
+ apiLog.error(err.message);
428
+ res.writeHead(500);
429
+ res.end(JSON.stringify({ error: err.message }));
430
+ }
431
+ }
432
+
336
433
  async function handleCsvImport(req, res, entity, thatcher, configEngineArg) {
337
434
  const user = await resolveRequestUser(req);
338
435
  if (!user) {
@@ -26,8 +26,17 @@ class PermissionService {
26
26
  return Array.isArray(allowed) && allowed.includes(user.role);
27
27
  }
28
28
 
29
+ checkOrganizationAccess(user, spec, record) {
30
+ if (!user) return false;
31
+ if (!spec.fields?.organization_id) return true;
32
+ if (record.organization_id == null) return true;
33
+ return record.organization_id === user.organization_id;
34
+ }
35
+
29
36
  checkRowAccess(user, spec, record) {
30
37
  if (!user) return false;
38
+ if (!this.checkOrganizationAccess(user, spec, record)) return false;
39
+
31
40
  const rowAccess = spec.rowAccess || spec.row_access;
32
41
  if (!rowAccess) return true;
33
42
 
package/src/ui/layout.js CHANGED
@@ -140,6 +140,7 @@ export function nav(user, pathname = '') {
140
140
  <div class="user-dropdown-email">${esc(user?.email || '')}</div>
141
141
  <div class="user-dropdown-role" style="text-transform:capitalize">${esc(user?.role || '')}</div>
142
142
  </div>
143
+ <div id="org-switcher" style="display:none"></div>
143
144
  <a href="/api/auth/logout" class="user-dropdown-item">
144
145
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
145
146
  Sign out
@@ -208,11 +209,13 @@ schedule()})();
208
209
 
209
210
  const NOTIF_SCRIPT = `(function(){function loadNotifCount(){fetch('/api/notifications?unread=1',{credentials:'same-origin'}).then(function(r){return r.json()}).then(function(d){var n=(d.data||[]).length;var el=document.getElementById('notif-count');if(!el)return;if(n>0){el.textContent=n>99?'99+':n;el.style.display='block'}else{el.style.display='none'}}).catch(function(){})}loadNotifCount();setInterval(loadNotifCount,60000)})();`;
210
211
 
212
+ const ORG_SWITCHER_SCRIPT = `(function(){var loaded=false;function loadMemberships(){if(loaded)return;var el=document.getElementById('org-switcher');if(!el)return;fetch('/api/organization/memberships',{credentials:'same-origin'}).then(function(r){if(!r.ok)return null;return r.json()}).then(function(d){if(!d||!d.organizations||d.organizations.length<2){return}loaded=true;var wrap=document.createElement('div');wrap.className='user-dropdown-item';wrap.style.cursor='default';var select=document.createElement('select');select.id='org-switcher-select';select.style.width='100%';d.organizations.forEach(function(o){var opt=document.createElement('option');opt.value=o.id;opt.textContent=o.name;if(o.id===d.active)opt.selected=true;select.appendChild(opt)});wrap.appendChild(select);el.innerHTML='';el.appendChild(wrap);el.style.display='block';select.addEventListener('change',function(e){fetch('/api/organization/switch',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:JSON.stringify({organization_id:e.target.value})}).then(function(r){if(r.ok)location.reload()})})}).catch(function(){})}document.getElementById('user-avatar')&&document.getElementById('user-avatar').addEventListener('click',loadMemberships)})();`;
213
+
211
214
  export function page(user, title, bc, content, scripts = []) {
212
215
  const authData = user ? JSON.stringify({ id: user.id, name: user.name, email: user.email, role: user.role }) : 'null'
213
216
  const authScript = `window.__AUTH__=${authData};`
214
217
  const body = `<div class="min-h-screen">${nav(user)}<main id="main-content" ${role.main} class="page-shell">${breadcrumb(bc)}${content}</main></div>`
215
- return generateHtml(title, body, [authScript, NOTIF_SCRIPT, ...scripts])
218
+ return generateHtml(title, body, [authScript, NOTIF_SCRIPT, ORG_SWITCHER_SCRIPT, ...scripts])
216
219
  }
217
220
 
218
221
  export function fullPage(user, title, content, scripts = []) {
@@ -67,7 +67,7 @@ async function handleSearch(user, req) {
67
67
  const searchableEntities = entityFilter ? allEntityNames.filter(e => e === entityFilter) : allEntityNames;
68
68
  const results = {};
69
69
  for (const eName of searchableEntities) {
70
- try { let items = await list(eName, {}); if (q) items = items.filter(i => JSON.stringify(i).toLowerCase().includes(q.toLowerCase())); if (statusFilter) items = items.filter(i => i.status === statusFilter); const spec = getSpec(eName); if (spec) items = resolveRefFields(items, spec); results[eName] = items.slice(0, 50); } catch {}
70
+ try { let items = await list(eName, {}, { user }); if (q) items = items.filter(i => JSON.stringify(i).toLowerCase().includes(q.toLowerCase())); if (statusFilter) items = items.filter(i => i.status === statusFilter); const spec = getSpec(eName); if (spec) items = resolveRefFields(items, spec); results[eName] = items.slice(0, 50); } catch {}
71
71
  }
72
72
  return renderAdvancedSearch(user, results, { teams, entityNames: allEntityNames });
73
73
  }
@@ -81,7 +81,7 @@ async function handleGenericEntityView(user, entityName, id) {
81
81
  return lazyEntityForm(entityName, null, resolvedSpec, user, true, await getRefOptions(resolvedSpec));
82
82
  }
83
83
  if (!canView(user, entityName)) return renderAccessDenied(user, entityName, 'view');
84
- const item = await get(entityName, id); if (!item) return null;
84
+ const item = await get(entityName, id, { user }); if (!item) return null;
85
85
  if (item.team_id && user.team_id && item.team_id !== user.team_id && !isPartner(user)) return renderAccessDenied(user, entityName, 'view');
86
86
  if (isClientUser(user) && user.client_id && item.client_id && item.client_id !== user.client_id) return renderAccessDenied(user, entityName, 'view');
87
87
  const [resolvedItem] = resolveRefFields([item], spec);
@@ -223,7 +223,7 @@ export async function handlePage(pathname, req, res) {
223
223
  const spec = getSpec(entityName); if (!spec) return null;
224
224
  if (isClientUser(user) && !canClientAccessEntity(user, entityName)) return renderAccessDenied(user, entityName, 'list');
225
225
  if (!canList(user, entityName)) return renderAccessDenied(user, entityName, 'list');
226
- let items = await list(entityName, {});
226
+ let items = await list(entityName, {}, { user });
227
227
  if (isClientUser(user) && user.client_id) items = items.filter(item => { if (item.client_id) return item.client_id === user.client_id; if (item.assigned_to) return item.assigned_to === user.id; return true; });
228
228
  items = resolveRefFields(items, spec);
229
229
  const params = reqUrl(req).searchParams;