thatcher 1.0.60 → 1.0.62

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.60",
3
+ "version": "1.0.62",
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
@@ -66,6 +66,9 @@ export class Thatcher {
66
66
  const { registerAutomationEngine } = await import(resolveModule('./lib/automation-engine.js'));
67
67
  registerAutomationEngine();
68
68
 
69
+ const { registerWebhookEngine } = await import(resolveModule('./lib/webhook-engine.js'));
70
+ registerWebhookEngine();
71
+
69
72
  // Hot reload
70
73
  if (this.options.server.hotReload) {
71
74
  this.setupHotReload();
@@ -0,0 +1,91 @@
1
+ import { get, update, remove } from './busybase/store.js';
2
+ import { validateUpdate, sanitizeData } from './validation/index.js';
3
+ import { requirePermission } from './auth-middleware.js';
4
+ import { permissionService } from '../services/permission.service.js';
5
+ import { executeHook } from './hook-engine.js';
6
+ import { logAction } from './busybase/audit.js';
7
+ import { now } from './id-helpers.js';
8
+ import { createLogger } from './logger.js';
9
+
10
+ const log = createLogger('[BulkOps]');
11
+ const MAX_BULK_IDS = 500;
12
+
13
+ async function bulkDeleteOne(entityName, spec, id, user) {
14
+ await requirePermission(user, spec, 'delete');
15
+ const existing = await get(entityName, id, { user });
16
+ if (!existing) throw new Error('Not found');
17
+ if (!permissionService.checkRowAccess(user, spec, existing)) throw new Error('Access denied');
18
+
19
+ let result;
20
+ if (spec.immutable === true && spec.immutable_strategy === 'move_to_archive') {
21
+ const archiveData = { archived: true, archived_at: now(), archived_by: user?.id };
22
+ result = await update(entityName, id, archiveData, user);
23
+ logAction(entityName, id, 'archive', user?.id, existing, archiveData);
24
+ } else if (spec.fields?.status) {
25
+ result = await update(entityName, id, { status: 'deleted' }, user);
26
+ logAction(entityName, id, 'delete', user?.id, existing, { status: 'deleted' });
27
+ } else {
28
+ result = await remove(entityName, id);
29
+ logAction(entityName, id, 'delete', user?.id, existing, null);
30
+ }
31
+ executeHook(`delete:${entityName}:after`, { entity: entityName, id, data: result, user }).catch(e => log.error(e.message));
32
+ return result;
33
+ }
34
+
35
+ async function bulkSetFieldOne(entityName, spec, id, user, field, value) {
36
+ await requirePermission(user, spec, 'edit');
37
+ const existing = await get(entityName, id, { user });
38
+ if (!existing) throw new Error('Not found');
39
+ if (!permissionService.checkRowAccess(user, spec, existing)) throw new Error('Access denied');
40
+
41
+ const rawData = { [field]: value };
42
+ permissionService.enforceEditPermissions(user, spec, rawData);
43
+
44
+ const errors = await validateUpdate(entityName, rawData, existing);
45
+ if (Object.keys(errors).length > 0) throw new Error(`Validation failed: ${JSON.stringify(errors)}`);
46
+
47
+ const sanitized = sanitizeData(entityName, rawData, spec, existing);
48
+ const record = await update(entityName, id, sanitized, user);
49
+ logAction(entityName, id, 'update', user?.id, existing, record);
50
+ executeHook(`update:${entityName}:after`, { entity: entityName, id, data: record, before: existing, after: record, user }).catch(e => log.error(e.message));
51
+ return record;
52
+ }
53
+
54
+ async function bulkTransitionOne(entityName, id, workflowName, toState, user) {
55
+ const { transition } = await import('./workflow-engine.js');
56
+ return transition(entityName, id, workflowName, toState, user, 'bulk operation');
57
+ }
58
+
59
+ export async function runBulkOperation(entityName, spec, ids, action, user) {
60
+ if (!Array.isArray(ids) || !ids.length) return { ok: false, error: 'ids array required and must be non-empty' };
61
+ if (ids.length > MAX_BULK_IDS) return { ok: false, error: `Cannot process more than ${MAX_BULK_IDS} ids in one bulk operation` };
62
+ if (!action || typeof action.type !== 'string') return { ok: false, error: 'action.type required' };
63
+
64
+ const uniqueIds = [...new Set(ids)];
65
+ const results = [];
66
+
67
+ for (const id of uniqueIds) {
68
+ try {
69
+ if (action.type === 'delete') {
70
+ await bulkDeleteOne(entityName, spec, id, user);
71
+ results.push({ id, success: true });
72
+ } else if (action.type === 'set_field') {
73
+ if (!action.field || typeof action.field !== 'string') throw new Error('action.field required');
74
+ await bulkSetFieldOne(entityName, spec, id, user, action.field, action.value);
75
+ results.push({ id, success: true });
76
+ } else if (action.type === 'transition') {
77
+ if (!action.workflow || !action.toState) throw new Error('action.workflow and action.toState required');
78
+ await bulkTransitionOne(entityName, id, action.workflow, action.toState, user);
79
+ results.push({ id, success: true });
80
+ } else {
81
+ results.push({ id, success: false, error: `Unknown action type "${action.type}"` });
82
+ }
83
+ } catch (error) {
84
+ results.push({ id, success: false, error: error.message });
85
+ }
86
+ }
87
+
88
+ const succeeded = results.filter(r => r.success).length;
89
+ const failed = results.length - succeeded;
90
+ return { ok: true, total: results.length, succeeded, failed, results };
91
+ }
@@ -41,10 +41,45 @@ function withMultiTenancyDefaults(masterConfig) {
41
41
  return { ...masterConfig, entities };
42
42
  }
43
43
 
44
+ const WEBHOOK_ENTITY_DEFAULT = {
45
+ label: 'Webhook',
46
+ label_plural: 'Webhooks',
47
+ system_entity: true,
48
+ fields: {
49
+ entity: { type: 'text', required: true, label: 'Entity' },
50
+ trigger: { type: 'text', required: true, label: 'Trigger' },
51
+ url: { type: 'text', required: true, label: 'URL' },
52
+ secret: { type: 'text', required: true, label: 'Secret', hidden: true },
53
+ enabled: { type: 'bool', default: true, label: 'Enabled' },
54
+ },
55
+ };
56
+
57
+ const WEBHOOK_DELIVERY_ENTITY_DEFAULT = {
58
+ label: 'Webhook Delivery',
59
+ label_plural: 'Webhook Deliveries',
60
+ system_entity: true,
61
+ fields: {
62
+ webhook_id: { type: 'ref', ref: 'webhook', required: true },
63
+ event: { type: 'text' },
64
+ status_code: { type: 'int' },
65
+ success: { type: 'bool' },
66
+ attempt: { type: 'int' },
67
+ error: { type: 'text' },
68
+ },
69
+ };
70
+
71
+ function withWebhookDefaults(masterConfig) {
72
+ const entities = { ...(masterConfig.entities || {}) };
73
+ let changed = false;
74
+ if (!entities.webhook) { entities.webhook = WEBHOOK_ENTITY_DEFAULT; changed = true; }
75
+ if (!entities.webhook_delivery) { entities.webhook_delivery = WEBHOOK_DELIVERY_ENTITY_DEFAULT; changed = true; }
76
+ return changed ? { ...masterConfig, entities } : masterConfig;
77
+ }
78
+
44
79
  export class ConfigGeneratorEngine {
45
80
  constructor(masterConfig) {
46
81
  if (!masterConfig) throw new Error('[ConfigGeneratorEngine] masterConfig is required');
47
- this.masterConfig = deepFreeze(withMultiTenancyDefaults(masterConfig));
82
+ this.masterConfig = deepFreeze(withWebhookDefaults(withMultiTenancyDefaults(masterConfig)));
48
83
  this.specCache = new LRUCache(100);
49
84
  this.debugMode = false;
50
85
  this._plugins = new Map();
@@ -0,0 +1,187 @@
1
+ import crypto from 'crypto';
2
+ import dns from 'dns/promises';
3
+ import { hookEngine } from './hook-engine.js';
4
+ import { list, create, get } from './busybase/store.js';
5
+ import { getConfigEngineSync } from './config-generator-engine.js';
6
+ import { createLogger } from './logger.js';
7
+
8
+ const log = createLogger('[WebhookEngine]');
9
+
10
+ const MAX_ATTEMPTS = 3;
11
+ const RETRY_BACKOFF_MS = [500, 2000, 5000];
12
+ const REQUEST_TIMEOUT_MS = 10000;
13
+
14
+ function ipv4ToLong(ip) {
15
+ const parts = ip.split('.').map(Number);
16
+ if (parts.length !== 4 || parts.some(p => isNaN(p) || p < 0 || p > 255)) return null;
17
+ return (parts[0] << 24) + (parts[1] << 16) + (parts[2] << 8) + parts[3];
18
+ }
19
+
20
+ function inRange(ip, base, bits) {
21
+ const ipLong = ipv4ToLong(ip);
22
+ const baseLong = ipv4ToLong(base);
23
+ if (ipLong === null || baseLong === null) return false;
24
+ const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0;
25
+ return (ipLong & mask) === (baseLong & mask);
26
+ }
27
+
28
+ const PRIVATE_RANGES = [
29
+ ['10.0.0.0', 8],
30
+ ['172.16.0.0', 12],
31
+ ['192.168.0.0', 16],
32
+ ['127.0.0.0', 8],
33
+ ['169.254.0.0', 16],
34
+ ['0.0.0.0', 8],
35
+ ];
36
+
37
+ export function isPrivateOrLoopbackIp(ip) {
38
+ if (ip === '::1' || ip.startsWith('fe80:') || ip.startsWith('fc') || ip.startsWith('fd')) return true;
39
+ return PRIVATE_RANGES.some(([base, bits]) => inRange(ip, base, bits));
40
+ }
41
+
42
+ export async function validateWebhookUrl(rawUrl, options = {}) {
43
+ let parsed;
44
+ try {
45
+ parsed = new URL(rawUrl);
46
+ } catch {
47
+ return { ok: false, error: 'Invalid URL' };
48
+ }
49
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
50
+ return { ok: false, error: `Scheme "${parsed.protocol}" not allowed, only http/https` };
51
+ }
52
+ const hostname = parsed.hostname;
53
+ if (hostname === 'localhost') {
54
+ if (options.allowPrivateTargets) return { ok: true };
55
+ return { ok: false, error: 'localhost targets are not allowed' };
56
+ }
57
+ const ipv4Literal = ipv4ToLong(hostname) !== null;
58
+ if (ipv4Literal) {
59
+ if (isPrivateOrLoopbackIp(hostname) && !options.allowPrivateTargets) {
60
+ return { ok: false, error: `Private/loopback IP target "${hostname}" not allowed` };
61
+ }
62
+ return { ok: true };
63
+ }
64
+ if (options.allowPrivateTargets) return { ok: true };
65
+ try {
66
+ const resolved = await dns.lookup(hostname, { all: true });
67
+ for (const { address, family } of resolved) {
68
+ if (family === 4 && isPrivateOrLoopbackIp(address)) {
69
+ return { ok: false, error: `Hostname "${hostname}" resolves to private/loopback address "${address}"` };
70
+ }
71
+ if (family === 6 && (address === '::1' || address.startsWith('fe80:') || address.startsWith('fc') || address.startsWith('fd'))) {
72
+ return { ok: false, error: `Hostname "${hostname}" resolves to private/loopback address "${address}"` };
73
+ }
74
+ }
75
+ return { ok: true };
76
+ } catch (e) {
77
+ return { ok: false, error: `Could not resolve hostname "${hostname}": ${e.message}` };
78
+ }
79
+ }
80
+
81
+ export function signPayload(secret, payloadString) {
82
+ return crypto.createHmac('sha256', secret).update(payloadString).digest('hex');
83
+ }
84
+
85
+ export function verifySignature(secret, payloadString, signature) {
86
+ const expected = signPayload(secret, payloadString);
87
+ const expectedBuf = Buffer.from(expected, 'hex');
88
+ const givenBuf = Buffer.from(signature || '', 'hex');
89
+ if (expectedBuf.length !== givenBuf.length) return false;
90
+ return crypto.timingSafeEqual(expectedBuf, givenBuf);
91
+ }
92
+
93
+ async function deliverOnce(webhook, payloadString) {
94
+ const signature = signPayload(webhook.secret, payloadString);
95
+ const controller = new AbortController();
96
+ const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
97
+ try {
98
+ const res = await fetch(webhook.url, {
99
+ method: 'POST',
100
+ headers: { 'Content-Type': 'application/json', 'X-Thatcher-Signature': signature },
101
+ body: payloadString,
102
+ signal: controller.signal,
103
+ });
104
+ return { success: res.ok, statusCode: res.status, error: res.ok ? null : `HTTP ${res.status}` };
105
+ } catch (e) {
106
+ return { success: false, statusCode: 0, error: e.message };
107
+ } finally {
108
+ clearTimeout(timeout);
109
+ }
110
+ }
111
+
112
+ async function deliverWithRetry(webhook, payloadString) {
113
+ let lastResult = null;
114
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
115
+ lastResult = await deliverOnce(webhook, payloadString);
116
+ try {
117
+ await create('webhook_delivery', {
118
+ webhook_id: webhook.id,
119
+ event: webhook.trigger,
120
+ status_code: lastResult.statusCode,
121
+ success: lastResult.success,
122
+ attempt,
123
+ error: lastResult.error || '',
124
+ }, null);
125
+ } catch (e) {
126
+ log.error('failed to write webhook_delivery log:', { message: e.message });
127
+ }
128
+ if (lastResult.success) return lastResult;
129
+ if (attempt < MAX_ATTEMPTS) {
130
+ await new Promise(r => setTimeout(r, RETRY_BACKOFF_MS[attempt - 1] || RETRY_BACKOFF_MS[RETRY_BACKOFF_MS.length - 1]));
131
+ }
132
+ }
133
+ return lastResult;
134
+ }
135
+
136
+ function normalizeContext(context) {
137
+ const data = context.data || context.record || {};
138
+ return { ...context, data, entity: context.entity, id: context.id ?? data.id };
139
+ }
140
+
141
+ async function dispatchWebhooks(entityName, trigger, rawContext) {
142
+ let webhooks;
143
+ try {
144
+ webhooks = await list('webhook', { entity: entityName, trigger, enabled: true });
145
+ } catch (e) {
146
+ log.error('failed to list webhooks:', { message: e.message });
147
+ return;
148
+ }
149
+ if (!webhooks.length) return;
150
+
151
+ const context = normalizeContext(rawContext);
152
+ const payload = { event: trigger, entity: entityName, id: context.id, data: context.data, timestamp: Math.floor(Date.now() / 1000) };
153
+ const payloadString = JSON.stringify(payload);
154
+
155
+ for (const webhook of webhooks) {
156
+ const validation = await validateWebhookUrl(webhook.url);
157
+ if (!validation.ok) {
158
+ log.error(`webhook ${webhook.id} URL rejected: ${validation.error}`);
159
+ continue;
160
+ }
161
+ deliverWithRetry(webhook, payloadString).catch(e => log.error('webhook delivery failed:', { message: e.message }));
162
+ }
163
+ }
164
+
165
+ let registered = false;
166
+
167
+ export function registerWebhookEngine() {
168
+ if (registered) return;
169
+ registered = true;
170
+
171
+ const config = getConfigEngineSync().getConfig();
172
+ const entityNames = Object.keys(config?.entities || {});
173
+
174
+ for (const entityName of entityNames) {
175
+ if (entityName === 'webhook' || entityName === 'webhook_delivery') continue;
176
+ for (const trigger of ['create', 'update', 'delete']) {
177
+ hookEngine.register(`${trigger}:${entityName}:after`, async (context) => {
178
+ dispatchWebhooks(entityName, trigger, context).catch(e => log.error('webhook dispatch failed:', { message: e.message }));
179
+ return context;
180
+ });
181
+ }
182
+ hookEngine.register(`transition:${entityName}`, async (context) => {
183
+ dispatchWebhooks(entityName, 'transition', context).catch(e => log.error('webhook dispatch failed:', { message: e.message }));
184
+ return context;
185
+ });
186
+ }
187
+ }
@@ -122,6 +122,22 @@ export function createServer(options) {
122
122
  return await handleUpdatePermissionTemplate(req, res, id, thatcher, configEngine);
123
123
  }
124
124
 
125
+ if (req.method === 'POST' && entity === 'webhook' && id === 'create' && !action) {
126
+ return await handleCreateWebhook(req, res, thatcher, configEngine);
127
+ }
128
+
129
+ if (req.method === 'POST' && entity === 'webhook' && id && action === 'update') {
130
+ return await handleUpdateWebhookRecord(req, res, id, thatcher, configEngine);
131
+ }
132
+
133
+ if (req.method === 'POST' && entity === 'webhook' && id && action === 'delete') {
134
+ return await handleDeleteWebhook(req, res, id, thatcher, configEngine);
135
+ }
136
+
137
+ if (req.method === 'POST' && id === 'bulk' && !action) {
138
+ return await handleBulkOperation(req, res, entity, thatcher, configEngine);
139
+ }
140
+
125
141
  // Check if user has custom route for this
126
142
  const userRoutePath = path.join(process.cwd(), 'app/api', ...parts, 'route.js');
127
143
  const routeExists = await fileExists(userRoutePath);
@@ -351,6 +367,192 @@ async function handleGenericCrud(req, res, entity, id, action, thatcher, configE
351
367
 
352
368
  const PERMISSION_ACTIONS = new Set(['list', 'view', 'create', 'edit', 'delete', 'archive', 'export', 'manage_settings']);
353
369
 
370
+ async function requireAuthedPartner(req, res) {
371
+ const user = await resolveRequestUser(req);
372
+ if (!user) {
373
+ res.writeHead(401, { 'Content-Type': 'application/json' });
374
+ res.end(JSON.stringify({ error: 'Authentication required' }));
375
+ return null;
376
+ }
377
+ const { isPartner } = await import('../ui/permissions-ui.js');
378
+ if (!isPartner(user)) {
379
+ res.writeHead(403, { 'Content-Type': 'application/json' });
380
+ res.end(JSON.stringify({ error: 'Forbidden' }));
381
+ return null;
382
+ }
383
+ return user;
384
+ }
385
+
386
+ async function handleBulkOperation(req, res, entityName, thatcher, configEngineArg) {
387
+ const user = await resolveRequestUser(req);
388
+ if (!user) {
389
+ res.writeHead(401, { 'Content-Type': 'application/json' });
390
+ res.end(JSON.stringify({ error: 'Authentication required' }));
391
+ return;
392
+ }
393
+
394
+ let configEngine = configEngineArg || thatcher?.configEngine || globalThis.__thatcherConfigEngine;
395
+ if (!configEngine) {
396
+ const { getConfigEngineSync } = await import('../lib/config-generator-engine.js');
397
+ configEngine = getConfigEngineSync();
398
+ }
399
+ let spec;
400
+ try {
401
+ spec = configEngine.generateEntitySpec(entityName);
402
+ } catch (e) {
403
+ res.writeHead(404);
404
+ res.end(JSON.stringify({ error: `Entity "${entityName}" not found` }));
405
+ return;
406
+ }
407
+
408
+ let body;
409
+ try {
410
+ body = await readBody(req);
411
+ } catch (e) {
412
+ res.writeHead(400);
413
+ res.end(JSON.stringify({ error: e.message }));
414
+ return;
415
+ }
416
+ const ids = Array.isArray(body?.ids) ? body.ids : null;
417
+ const action = body?.action;
418
+ if (!ids) {
419
+ res.writeHead(400);
420
+ res.end(JSON.stringify({ error: 'ids array required' }));
421
+ return;
422
+ }
423
+
424
+ try {
425
+ const { runBulkOperation } = await import('../lib/bulk-operations.js');
426
+ const result = await runBulkOperation(entityName, spec, ids, action, user);
427
+ if (!result.ok) {
428
+ res.writeHead(400);
429
+ res.end(JSON.stringify({ error: result.error }));
430
+ return;
431
+ }
432
+ res.writeHead(200, { 'Content-Type': 'application/json' });
433
+ res.end(JSON.stringify(result));
434
+ } catch (err) {
435
+ apiLog.error(err.message);
436
+ res.writeHead(500);
437
+ res.end(JSON.stringify({ error: err.message }));
438
+ }
439
+ }
440
+
441
+ async function handleCreateWebhook(req, res, thatcher, configEngineArg) {
442
+ const user = await requireAuthedPartner(req, res);
443
+ if (!user) return;
444
+
445
+ let body;
446
+ try {
447
+ body = await readBody(req);
448
+ } catch (e) {
449
+ res.writeHead(400);
450
+ res.end(JSON.stringify({ error: e.message }));
451
+ return;
452
+ }
453
+ const { entity, trigger, url: targetUrl } = body || {};
454
+ if (!entity || typeof entity !== 'string') {
455
+ res.writeHead(400);
456
+ res.end(JSON.stringify({ error: 'entity required' }));
457
+ return;
458
+ }
459
+ if (!['create', 'update', 'delete', 'transition'].includes(trigger)) {
460
+ res.writeHead(400);
461
+ res.end(JSON.stringify({ error: 'trigger must be one of create/update/delete/transition' }));
462
+ return;
463
+ }
464
+ const { validateWebhookUrl } = await import('../lib/webhook-engine.js');
465
+ const validation = await validateWebhookUrl(targetUrl || '');
466
+ if (!validation.ok) {
467
+ res.writeHead(400);
468
+ res.end(JSON.stringify({ error: validation.error }));
469
+ return;
470
+ }
471
+
472
+ try {
473
+ const crypto = await import('crypto');
474
+ const secret = crypto.randomBytes(32).toString('hex');
475
+ const { create } = await import('../lib/busybase/store.js');
476
+ const record = await create('webhook', { entity, trigger, url: targetUrl, secret, enabled: true }, user);
477
+ res.writeHead(201, { 'Content-Type': 'application/json' });
478
+ res.end(JSON.stringify({ ok: true, id: record.id }));
479
+ } catch (err) {
480
+ apiLog.error(err.message);
481
+ res.writeHead(500);
482
+ res.end(JSON.stringify({ error: err.message }));
483
+ }
484
+ }
485
+
486
+ async function handleUpdateWebhookRecord(req, res, webhookId, thatcher, configEngineArg) {
487
+ const user = await requireAuthedPartner(req, res);
488
+ if (!user) return;
489
+
490
+ let body;
491
+ try {
492
+ body = await readBody(req);
493
+ } catch (e) {
494
+ res.writeHead(400);
495
+ res.end(JSON.stringify({ error: e.message }));
496
+ return;
497
+ }
498
+ const patch = {};
499
+ if (typeof body?.enabled === 'boolean') patch.enabled = body.enabled;
500
+ if (typeof body?.url === 'string') {
501
+ const { validateWebhookUrl } = await import('../lib/webhook-engine.js');
502
+ const validation = await validateWebhookUrl(body.url);
503
+ if (!validation.ok) {
504
+ res.writeHead(400);
505
+ res.end(JSON.stringify({ error: validation.error }));
506
+ return;
507
+ }
508
+ patch.url = body.url;
509
+ }
510
+ if (!Object.keys(patch).length) {
511
+ res.writeHead(400);
512
+ res.end(JSON.stringify({ error: 'nothing to update' }));
513
+ return;
514
+ }
515
+
516
+ try {
517
+ const { update, get } = await import('../lib/busybase/store.js');
518
+ const existing = await get('webhook', webhookId);
519
+ if (!existing) {
520
+ res.writeHead(404);
521
+ res.end(JSON.stringify({ error: 'Webhook not found' }));
522
+ return;
523
+ }
524
+ await update('webhook', webhookId, patch);
525
+ res.writeHead(200, { 'Content-Type': 'application/json' });
526
+ res.end(JSON.stringify({ ok: true, id: webhookId }));
527
+ } catch (err) {
528
+ apiLog.error(err.message);
529
+ res.writeHead(500);
530
+ res.end(JSON.stringify({ error: err.message }));
531
+ }
532
+ }
533
+
534
+ async function handleDeleteWebhook(req, res, webhookId, thatcher, configEngineArg) {
535
+ const user = await requireAuthedPartner(req, res);
536
+ if (!user) return;
537
+
538
+ try {
539
+ const { remove, get } = await import('../lib/busybase/store.js');
540
+ const existing = await get('webhook', webhookId);
541
+ if (!existing) {
542
+ res.writeHead(404);
543
+ res.end(JSON.stringify({ error: 'Webhook not found' }));
544
+ return;
545
+ }
546
+ await remove('webhook', webhookId);
547
+ res.writeHead(200, { 'Content-Type': 'application/json' });
548
+ res.end(JSON.stringify({ ok: true }));
549
+ } catch (err) {
550
+ apiLog.error(err.message);
551
+ res.writeHead(500);
552
+ res.end(JSON.stringify({ error: err.message }));
553
+ }
554
+ }
555
+
354
556
  async function handleUpdatePermissionTemplate(req, res, templateName, thatcher, configEngineArg) {
355
557
  const user = await resolveRequestUser(req);
356
558
  if (!user) {
@@ -30,7 +30,8 @@ function gridRow(entityName, item, columns) {
30
30
  : '';
31
31
  return `<td data-col="${esc(key)}"${editableAttrs}>${rendered}</td>`;
32
32
  }).join('');
33
- return `<tr data-row data-navigate="/${esc(entityName)}/${esc(item.id)}" style="cursor:pointer">${cells}</tr>`;
33
+ const checkboxCell = `<td style="width:32px"><input type="checkbox" class="bulk-row-select" data-row-id="${esc(item.id)}" onclick="event.stopPropagation()"></td>`;
34
+ return `<tr data-row data-navigate="/${esc(entityName)}/${esc(item.id)}" style="cursor:pointer">${checkboxCell}${cells}</tr>`;
34
35
  }
35
36
 
36
37
  const GRID_EDIT_SCRIPT = `(function(){
@@ -77,6 +78,63 @@ const GRID_EDIT_SCRIPT = `(function(){
77
78
  },true);
78
79
  })();`;
79
80
 
81
+ const BULK_OPS_SCRIPT = `(function(){
82
+ function selectedIds(){
83
+ return Array.prototype.slice.call(document.querySelectorAll('.bulk-row-select:checked')).map(function(cb){return cb.getAttribute('data-row-id')});
84
+ }
85
+ function updateToolbar(){
86
+ var ids=selectedIds();
87
+ var toolbar=document.getElementById('bulk-toolbar');
88
+ var label=document.getElementById('bulk-count-label');
89
+ if(ids.length>0){toolbar.style.display='flex';label.textContent=ids.length+' selected'}
90
+ else{toolbar.style.display='none'}
91
+ }
92
+ document.addEventListener('change',function(e){
93
+ if(e.target.classList&&e.target.classList.contains('bulk-row-select')){updateToolbar()}
94
+ if(e.target.id==='bulk-select-all'){
95
+ var checked=e.target.checked;
96
+ document.querySelectorAll('.bulk-row-select').forEach(function(cb){cb.checked=checked});
97
+ updateToolbar();
98
+ }
99
+ });
100
+ function postBulk(entity,ids,action,onDone){
101
+ var status=document.getElementById('bulk-status');
102
+ status.textContent='Processing...';
103
+ fetch('/api/'+entity+'/bulk',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids:ids,action:action})})
104
+ .then(function(r){return r.json().then(function(d){return {ok:r.ok,d:d}})})
105
+ .then(function(res){
106
+ if(res.ok){
107
+ var failed=res.d.failed||0;
108
+ status.textContent=res.d.succeeded+' succeeded, '+failed+' failed';
109
+ if(failed===0){setTimeout(function(){location.reload()},800)}
110
+ }else{status.textContent='Error: '+(res.d.error||'bulk operation failed')}
111
+ if(onDone)onDone();
112
+ })
113
+ .catch(function(err){status.textContent='Error: '+err.message});
114
+ }
115
+ window.bulkDelete=function(entity){
116
+ var ids=selectedIds();
117
+ if(!ids.length)return;
118
+ if(!window.confirm('Delete '+ids.length+' items?'))return;
119
+ postBulk(entity,ids,{type:'delete'});
120
+ };
121
+ window.bulkSetField=function(entity){
122
+ var ids=selectedIds();
123
+ if(!ids.length)return;
124
+ var field=document.getElementById('bulk-set-field').value;
125
+ var value=document.getElementById('bulk-set-value').value;
126
+ if(!field)return;
127
+ postBulk(entity,ids,{type:'set_field',field:field,value:value});
128
+ };
129
+ window.bulkTransition=function(entity,workflow){
130
+ var ids=selectedIds();
131
+ if(!ids.length)return;
132
+ var toState=document.getElementById('bulk-transition-target').value;
133
+ if(!toState)return;
134
+ postBulk(entity,ids,{type:'transition',workflow:workflow,toState:toState});
135
+ };
136
+ })();`;
137
+
80
138
  export function renderGridView(user, entityName, spec, records, options = {}) {
81
139
  const label = getEntityLabel(spec, true) || entityName;
82
140
  const columns = getColumns(spec);
@@ -105,9 +163,32 @@ export function renderGridView(user, entityName, spec, records, options = {}) {
105
163
  const rows = records.map(item => gridRow(entityName, item, columns)).join('') ||
106
164
  emptyRow(columns.length || 1, `No ${esc(label.toLowerCase())} found`);
107
165
 
166
+ const editableFieldOpts = columns.filter(([, f]) => isEditable(f)).map(([key, f]) =>
167
+ `<option value="${esc(key)}">${esc(f.label || key)}</option>`
168
+ ).join('');
169
+
170
+ const workflowStageOpts = spec.workflowDef?.stages
171
+ ? spec.workflowDef.stages.map(s => `<option value="${esc(s.name)}">${esc(s.label || s.name)}</option>`).join('')
172
+ : '';
173
+ const transitionButton = spec.workflow && workflowStageOpts
174
+ ? `<select id="bulk-transition-target"><option value="">Transition to...</option>${workflowStageOpts}</select>
175
+ <button type="button" class="btn-ghost-clean" data-action="bulkTransition" data-args='["${esc(entityName)}","${esc(spec.workflow)}"]'>Apply</button>`
176
+ : '';
177
+
178
+ const bulkToolbar = `<div id="bulk-toolbar" style="display:none;align-items:center;gap:8px;padding:8px;background:var(--color-bg-secondary,#f5f5f5);border-radius:4px;margin-bottom:8px;flex-wrap:wrap">
179
+ <span id="bulk-count-label" style="font-size:13px;font-weight:600"></span>
180
+ <button type="button" class="btn-danger-clean" data-action="bulkDelete" data-args='["${esc(entityName)}"]'>Delete Selected</button>
181
+ <select id="bulk-set-field"><option value="">Set field...</option>${editableFieldOpts}</select>
182
+ <input type="text" id="bulk-set-value" placeholder="value" style="width:120px">
183
+ <button type="button" class="btn-ghost-clean" data-action="bulkSetField" data-args='["${esc(entityName)}"]'>Apply</button>
184
+ ${transitionButton}
185
+ <span id="bulk-status" style="font-size:13px"></span>
186
+ </div>`;
187
+
108
188
  const content = `<div class="page-header">
109
189
  <div><h1 class="page-title">${esc(label)}</h1><p class="page-subtitle">${records.length} total ${esc(label.toLowerCase())}</p></div>
110
190
  </div>
191
+ ${bulkToolbar}
111
192
  <div class="table-wrap">
112
193
  <div class="table-toolbar">
113
194
  <div class="table-search"><input id="search-input" type="text" placeholder="Search ${esc(label.toLowerCase())}..."></div>
@@ -115,10 +196,10 @@ export function renderGridView(user, entityName, spec, records, options = {}) {
115
196
  <span class="table-count" id="row-count">${records.length} items</span>
116
197
  </div>
117
198
  <table class="data-table" role="grid" data-page-size="${esc(pageSize)}">
118
- <thead><tr>${headerCells}</tr></thead>
199
+ <thead><tr><th style="width:32px"><input type="checkbox" id="bulk-select-all"></th>${headerCells}</tr></thead>
119
200
  <tbody>${rows}</tbody>
120
201
  </table>
121
202
  </div>`;
122
203
 
123
- return page(user, `${label} | Thatcher`, null, content, [TABLE_SCRIPT, GRID_EDIT_SCRIPT]);
204
+ return page(user, `${label} | Thatcher`, null, content, [TABLE_SCRIPT, GRID_EDIT_SCRIPT, BULK_OPS_SCRIPT]);
124
205
  }
@@ -8,6 +8,7 @@ import { renderChecklistsManagement } from '@/ui/checklist-renderer.js';
8
8
  import { renderJobManagement } from '@/ui/job-management-renderer.js';
9
9
  import { renderWorkflowList, renderWorkflowEditor } from '@/ui/workflow-builder-renderer.js';
10
10
  import { renderRolesList, renderTemplateList, renderPermissionMatrix } from '@/ui/rbac-renderer.js';
11
+ import { renderWebhookList, renderWebhookDetail } from '@/ui/webhook-renderer.js';
11
12
  import { isPartner, isManager } from '@/ui/permissions-ui.js';
12
13
  import { getSystemConfig, getSettingsCounts, getAuditData, getSystemHealth, renderBuildLogsContent } from '@/ui/page-handler-helpers.js';
13
14
  import { fileURLToPath } from 'url';
@@ -163,5 +164,19 @@ export async function handleAdminPage(normalized, segments, user) {
163
164
  const roleNames = Object.keys(engine.getRoles());
164
165
  return renderPermissionMatrix(user, segments[2], roleNames, roleActionsMap);
165
166
  }
167
+ if (normalized === '/admin/webhooks') {
168
+ let webhooks = []; try { webhooks = await list('webhook', {}); } catch {}
169
+ const { getConfigEngineSync } = await import('@/lib/config-generator-engine.js');
170
+ const engine = getConfigEngineSync();
171
+ const entityNames = engine.getAllEntities().filter(e => e !== 'webhook' && e !== 'webhook_delivery');
172
+ return renderWebhookList(user, webhooks, entityNames);
173
+ }
174
+ if (segments.length === 3 && segments[1] === 'webhooks') {
175
+ const webhook = await get('webhook', segments[2]);
176
+ if (!webhook) return null;
177
+ let deliveries = [];
178
+ try { deliveries = (await list('webhook_delivery', { webhook_id: segments[2] }, { sort: { field: 'created_at', dir: 'DESC' } })).slice(0, 20); } catch {}
179
+ return renderWebhookDetail(user, webhook, deliveries);
180
+ }
166
181
  return null;
167
182
  }
@@ -0,0 +1,83 @@
1
+ import { page } from '@/ui/layout.js';
2
+ import { esc } from '@/ui/render-helpers.js';
3
+
4
+ export function renderWebhookList(user, webhooks, entityNames) {
5
+ const rows = webhooks.map(w =>
6
+ `<tr data-row data-navigate="/admin/webhooks/${esc(w.id)}" style="cursor:pointer">
7
+ <td>${esc(w.entity)}</td><td>${esc(w.trigger)}</td><td>${esc(w.url)}</td>
8
+ <td>${w.enabled ? 'Enabled' : 'Disabled'}</td>
9
+ </tr>`
10
+ ).join('') || '<tr><td colspan="4">No webhooks configured</td></tr>';
11
+
12
+ const entityOpts = entityNames.map(e => `<option value="${esc(e)}">${esc(e)}</option>`).join('');
13
+ const triggerOpts = ['create', 'update', 'delete', 'transition'].map(t => `<option value="${esc(t)}">${esc(t)}</option>`).join('');
14
+
15
+ const content = `<div class="page-header"><h1 class="page-title">Webhooks</h1></div>
16
+ <div class="table-wrap"><table class="data-table"><thead><tr><th>Entity</th><th>Trigger</th><th>URL</th><th>Status</th></tr></thead><tbody>${rows}</tbody></table></div>
17
+ <div class="card-clean" style="margin-top:16px"><div class="card-clean-body">
18
+ <h3 style="margin-bottom:8px">Add Webhook</h3>
19
+ <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
20
+ <select id="new-wh-entity">${entityOpts}</select>
21
+ <select id="new-wh-trigger">${triggerOpts}</select>
22
+ <input type="text" id="new-wh-url" placeholder="https://example.com/hook" style="flex:1;min-width:200px">
23
+ <button type="button" class="btn-primary-clean" data-action="createWebhook">Add</button>
24
+ </div>
25
+ <span id="create-status" style="margin-left:8px;font-size:13px"></span>
26
+ </div></div>`;
27
+
28
+ const script = `(function(){
29
+ window.createWebhook=function(){
30
+ var entity=document.getElementById('new-wh-entity').value;
31
+ var trigger=document.getElementById('new-wh-trigger').value;
32
+ var urlInput=document.getElementById('new-wh-url');
33
+ var url=urlInput.value.trim();
34
+ var status=document.getElementById('create-status');
35
+ if(!url){status.textContent='URL required';return}
36
+ status.textContent='Creating...';
37
+ fetch('/api/webhook/create',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({entity:entity,trigger:trigger,url:url})})
38
+ .then(function(r){return r.json().then(function(d){return {ok:r.ok,d:d}})})
39
+ .then(function(res){if(res.ok){status.textContent='Created';location.reload()}else{status.textContent='Error: '+(res.d.error||'create failed')}})
40
+ .catch(function(err){status.textContent='Error: '+err.message});
41
+ };
42
+ })();`;
43
+
44
+ return page(user, 'Webhooks | Thatcher', [{ href: '/admin/settings', label: 'Settings' }, { label: 'Webhooks' }], content, [script]);
45
+ }
46
+
47
+ export function renderWebhookDetail(user, webhook, deliveries) {
48
+ const deliveryRows = deliveries.map(d =>
49
+ `<tr><td>${esc(String(d.attempt))}</td><td>${d.success ? 'Success' : 'Failed'}</td><td>${esc(String(d.status_code ?? '-'))}</td><td>${esc(d.error || '-')}</td></tr>`
50
+ ).join('') || '<tr><td colspan="4">No deliveries yet</td></tr>';
51
+
52
+ const content = `<div class="page-header"><h1 class="page-title">Webhook: ${esc(webhook.entity)}.${esc(webhook.trigger)}</h1></div>
53
+ <div class="card-clean" style="margin-bottom:16px"><div class="card-clean-body">
54
+ <div style="margin-bottom:8px"><strong>URL:</strong> ${esc(webhook.url)}</div>
55
+ <div style="margin-bottom:8px"><strong>Status:</strong> <span id="wh-status-label">${webhook.enabled ? 'Enabled' : 'Disabled'}</span></div>
56
+ <button type="button" class="btn-ghost-clean" data-action="toggleWebhook" data-args='["${esc(webhook.id)}",${webhook.enabled ? 'false' : 'true'}]'>${webhook.enabled ? 'Disable' : 'Enable'}</button>
57
+ <button type="button" class="btn-danger-clean" data-action="deleteWebhook" data-args='["${esc(webhook.id)}"]'>Delete</button>
58
+ <span id="action-status" style="margin-left:8px;font-size:13px"></span>
59
+ </div></div>
60
+ <h3>Recent Deliveries</h3>
61
+ <div class="table-wrap"><table class="data-table"><thead><tr><th>Attempt</th><th>Result</th><th>Status Code</th><th>Error</th></tr></thead><tbody>${deliveryRows}</tbody></table></div>`;
62
+
63
+ const script = `(function(){
64
+ window.toggleWebhook=function(id,nextEnabled){
65
+ var status=document.getElementById('action-status');
66
+ status.textContent='Saving...';
67
+ fetch('/api/webhook/'+id+'/update',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:nextEnabled})})
68
+ .then(function(r){return r.json().then(function(d){return {ok:r.ok,d:d}})})
69
+ .then(function(res){if(res.ok){location.reload()}else{status.textContent='Error: '+(res.d.error||'update failed')}})
70
+ .catch(function(err){status.textContent='Error: '+err.message});
71
+ };
72
+ window.deleteWebhook=function(id){
73
+ var status=document.getElementById('action-status');
74
+ status.textContent='Deleting...';
75
+ fetch('/api/webhook/'+id+'/delete',{method:'POST'})
76
+ .then(function(r){return r.json().then(function(d){return {ok:r.ok,d:d}})})
77
+ .then(function(res){if(res.ok){window.location='/admin/webhooks'}else{status.textContent='Error: '+(res.d.error||'delete failed')}})
78
+ .catch(function(err){status.textContent='Error: '+err.message});
79
+ };
80
+ })();`;
81
+
82
+ return page(user, `Webhook: ${webhook.entity}.${webhook.trigger} | Thatcher`, [{ href: '/admin/settings', label: 'Settings' }, { href: '/admin/webhooks', label: 'Webhooks' }, { label: `${webhook.entity}.${webhook.trigger}` }], content, [script]);
83
+ }