thatcher 1.0.59 → 1.0.61
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 +1 -1
- package/src/index.js +3 -0
- package/src/lib/config-generator-engine.js +46 -1
- package/src/lib/webhook-engine.js +187 -0
- package/src/server/server.js +221 -0
- package/src/ui/page-handler-admin.js +36 -0
- package/src/ui/rbac-renderer.js +63 -0
- package/src/ui/webhook-renderer.js +83 -0
package/package.json
CHANGED
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();
|
|
@@ -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();
|
|
@@ -64,6 +99,16 @@ export class ConfigGeneratorEngine {
|
|
|
64
99
|
return this;
|
|
65
100
|
}
|
|
66
101
|
|
|
102
|
+
updatePermissionTemplate(templateName, roleActionsMap) {
|
|
103
|
+
if (!templateName || typeof templateName !== 'string') {
|
|
104
|
+
throw new Error('[ConfigGeneratorEngine] updatePermissionTemplate: templateName required');
|
|
105
|
+
}
|
|
106
|
+
const nextTemplates = { ...(this.masterConfig.permission_templates || {}), [templateName]: roleActionsMap };
|
|
107
|
+
this.masterConfig = deepFreeze({ ...this.masterConfig, permission_templates: nextTemplates });
|
|
108
|
+
this.specCache.clear();
|
|
109
|
+
return this;
|
|
110
|
+
}
|
|
111
|
+
|
|
67
112
|
registerPlugin(entityName, plugin = {}) {
|
|
68
113
|
if (!entityName || typeof entityName !== 'string') {
|
|
69
114
|
throw new Error('[ConfigGeneratorEngine] registerPlugin: entityName required');
|
|
@@ -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
|
+
}
|
package/src/server/server.js
CHANGED
|
@@ -118,6 +118,22 @@ export function createServer(options) {
|
|
|
118
118
|
return await handleUpdateWorkflow(req, res, id, thatcher, configEngine);
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
if (req.method === 'POST' && entity === 'permission-template' && id && action === 'update') {
|
|
122
|
+
return await handleUpdatePermissionTemplate(req, res, id, thatcher, configEngine);
|
|
123
|
+
}
|
|
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
|
+
|
|
121
137
|
// Check if user has custom route for this
|
|
122
138
|
const userRoutePath = path.join(process.cwd(), 'app/api', ...parts, 'route.js');
|
|
123
139
|
const routeExists = await fileExists(userRoutePath);
|
|
@@ -345,6 +361,211 @@ async function handleGenericCrud(req, res, entity, id, action, thatcher, configE
|
|
|
345
361
|
}
|
|
346
362
|
}
|
|
347
363
|
|
|
364
|
+
const PERMISSION_ACTIONS = new Set(['list', 'view', 'create', 'edit', 'delete', 'archive', 'export', 'manage_settings']);
|
|
365
|
+
|
|
366
|
+
async function requireAuthedPartner(req, res) {
|
|
367
|
+
const user = await resolveRequestUser(req);
|
|
368
|
+
if (!user) {
|
|
369
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
370
|
+
res.end(JSON.stringify({ error: 'Authentication required' }));
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
const { isPartner } = await import('../ui/permissions-ui.js');
|
|
374
|
+
if (!isPartner(user)) {
|
|
375
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
376
|
+
res.end(JSON.stringify({ error: 'Forbidden' }));
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
return user;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function handleCreateWebhook(req, res, thatcher, configEngineArg) {
|
|
383
|
+
const user = await requireAuthedPartner(req, res);
|
|
384
|
+
if (!user) return;
|
|
385
|
+
|
|
386
|
+
let body;
|
|
387
|
+
try {
|
|
388
|
+
body = await readBody(req);
|
|
389
|
+
} catch (e) {
|
|
390
|
+
res.writeHead(400);
|
|
391
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const { entity, trigger, url: targetUrl } = body || {};
|
|
395
|
+
if (!entity || typeof entity !== 'string') {
|
|
396
|
+
res.writeHead(400);
|
|
397
|
+
res.end(JSON.stringify({ error: 'entity required' }));
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
if (!['create', 'update', 'delete', 'transition'].includes(trigger)) {
|
|
401
|
+
res.writeHead(400);
|
|
402
|
+
res.end(JSON.stringify({ error: 'trigger must be one of create/update/delete/transition' }));
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const { validateWebhookUrl } = await import('../lib/webhook-engine.js');
|
|
406
|
+
const validation = await validateWebhookUrl(targetUrl || '');
|
|
407
|
+
if (!validation.ok) {
|
|
408
|
+
res.writeHead(400);
|
|
409
|
+
res.end(JSON.stringify({ error: validation.error }));
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
try {
|
|
414
|
+
const crypto = await import('crypto');
|
|
415
|
+
const secret = crypto.randomBytes(32).toString('hex');
|
|
416
|
+
const { create } = await import('../lib/busybase/store.js');
|
|
417
|
+
const record = await create('webhook', { entity, trigger, url: targetUrl, secret, enabled: true }, user);
|
|
418
|
+
res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
419
|
+
res.end(JSON.stringify({ ok: true, id: record.id }));
|
|
420
|
+
} catch (err) {
|
|
421
|
+
apiLog.error(err.message);
|
|
422
|
+
res.writeHead(500);
|
|
423
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function handleUpdateWebhookRecord(req, res, webhookId, thatcher, configEngineArg) {
|
|
428
|
+
const user = await requireAuthedPartner(req, res);
|
|
429
|
+
if (!user) return;
|
|
430
|
+
|
|
431
|
+
let body;
|
|
432
|
+
try {
|
|
433
|
+
body = await readBody(req);
|
|
434
|
+
} catch (e) {
|
|
435
|
+
res.writeHead(400);
|
|
436
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
const patch = {};
|
|
440
|
+
if (typeof body?.enabled === 'boolean') patch.enabled = body.enabled;
|
|
441
|
+
if (typeof body?.url === 'string') {
|
|
442
|
+
const { validateWebhookUrl } = await import('../lib/webhook-engine.js');
|
|
443
|
+
const validation = await validateWebhookUrl(body.url);
|
|
444
|
+
if (!validation.ok) {
|
|
445
|
+
res.writeHead(400);
|
|
446
|
+
res.end(JSON.stringify({ error: validation.error }));
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
patch.url = body.url;
|
|
450
|
+
}
|
|
451
|
+
if (!Object.keys(patch).length) {
|
|
452
|
+
res.writeHead(400);
|
|
453
|
+
res.end(JSON.stringify({ error: 'nothing to update' }));
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
try {
|
|
458
|
+
const { update, get } = await import('../lib/busybase/store.js');
|
|
459
|
+
const existing = await get('webhook', webhookId);
|
|
460
|
+
if (!existing) {
|
|
461
|
+
res.writeHead(404);
|
|
462
|
+
res.end(JSON.stringify({ error: 'Webhook not found' }));
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
await update('webhook', webhookId, patch);
|
|
466
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
467
|
+
res.end(JSON.stringify({ ok: true, id: webhookId }));
|
|
468
|
+
} catch (err) {
|
|
469
|
+
apiLog.error(err.message);
|
|
470
|
+
res.writeHead(500);
|
|
471
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function handleDeleteWebhook(req, res, webhookId, thatcher, configEngineArg) {
|
|
476
|
+
const user = await requireAuthedPartner(req, res);
|
|
477
|
+
if (!user) return;
|
|
478
|
+
|
|
479
|
+
try {
|
|
480
|
+
const { remove, get } = await import('../lib/busybase/store.js');
|
|
481
|
+
const existing = await get('webhook', webhookId);
|
|
482
|
+
if (!existing) {
|
|
483
|
+
res.writeHead(404);
|
|
484
|
+
res.end(JSON.stringify({ error: 'Webhook not found' }));
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
await remove('webhook', webhookId);
|
|
488
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
489
|
+
res.end(JSON.stringify({ ok: true }));
|
|
490
|
+
} catch (err) {
|
|
491
|
+
apiLog.error(err.message);
|
|
492
|
+
res.writeHead(500);
|
|
493
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async function handleUpdatePermissionTemplate(req, res, templateName, thatcher, configEngineArg) {
|
|
498
|
+
const user = await resolveRequestUser(req);
|
|
499
|
+
if (!user) {
|
|
500
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
501
|
+
res.end(JSON.stringify({ error: 'Authentication required' }));
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
const { isPartner } = await import('../ui/permissions-ui.js');
|
|
505
|
+
if (!isPartner(user)) {
|
|
506
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
507
|
+
res.end(JSON.stringify({ error: 'Forbidden' }));
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
let configEngine = configEngineArg || thatcher?.configEngine || globalThis.__thatcherConfigEngine;
|
|
512
|
+
if (!configEngine) {
|
|
513
|
+
const { getConfigEngineSync } = await import('../lib/config-generator-engine.js');
|
|
514
|
+
configEngine = getConfigEngineSync();
|
|
515
|
+
}
|
|
516
|
+
const existingTemplate = configEngine.getConfig().permission_templates?.[templateName];
|
|
517
|
+
if (!existingTemplate) {
|
|
518
|
+
res.writeHead(404);
|
|
519
|
+
res.end(JSON.stringify({ error: `Permission template "${templateName}" not found` }));
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
const validRoles = new Set(Object.keys(configEngine.getRoles()));
|
|
523
|
+
|
|
524
|
+
let body;
|
|
525
|
+
try {
|
|
526
|
+
body = await readBody(req);
|
|
527
|
+
} catch (e) {
|
|
528
|
+
res.writeHead(400);
|
|
529
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
const roles = body && typeof body.roles === 'object' && !Array.isArray(body.roles) ? body.roles : null;
|
|
533
|
+
if (!roles) {
|
|
534
|
+
res.writeHead(400);
|
|
535
|
+
res.end(JSON.stringify({ error: 'roles object required' }));
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
for (const [roleName, actions] of Object.entries(roles)) {
|
|
539
|
+
if (!validRoles.has(roleName)) {
|
|
540
|
+
res.writeHead(400);
|
|
541
|
+
res.end(JSON.stringify({ error: `unknown role "${roleName}"` }));
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
if (!Array.isArray(actions)) {
|
|
545
|
+
res.writeHead(400);
|
|
546
|
+
res.end(JSON.stringify({ error: `actions for role "${roleName}" must be an array` }));
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
for (const a of actions) {
|
|
550
|
+
if (!PERMISSION_ACTIONS.has(a)) {
|
|
551
|
+
res.writeHead(400);
|
|
552
|
+
res.end(JSON.stringify({ error: `unknown action "${a}" for role "${roleName}"` }));
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
try {
|
|
559
|
+
configEngine.updatePermissionTemplate(templateName, roles);
|
|
560
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
561
|
+
res.end(JSON.stringify({ ok: true, template: templateName, roleCount: Object.keys(roles).length }));
|
|
562
|
+
} catch (err) {
|
|
563
|
+
apiLog.error(err.message);
|
|
564
|
+
res.writeHead(500);
|
|
565
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
348
569
|
async function handleUpdateWorkflow(req, res, workflowName, thatcher, configEngineArg) {
|
|
349
570
|
const user = await resolveRequestUser(req);
|
|
350
571
|
if (!user) {
|
|
@@ -7,6 +7,8 @@ import { renderSettingsNotifications, renderSettingsIntegrations, renderSettings
|
|
|
7
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
|
+
import { renderRolesList, renderTemplateList, renderPermissionMatrix } from '@/ui/rbac-renderer.js';
|
|
11
|
+
import { renderWebhookList, renderWebhookDetail } from '@/ui/webhook-renderer.js';
|
|
10
12
|
import { isPartner, isManager } from '@/ui/permissions-ui.js';
|
|
11
13
|
import { getSystemConfig, getSettingsCounts, getAuditData, getSystemHealth, renderBuildLogsContent } from '@/ui/page-handler-helpers.js';
|
|
12
14
|
import { fileURLToPath } from 'url';
|
|
@@ -142,5 +144,39 @@ export async function handleAdminPage(normalized, segments, user) {
|
|
|
142
144
|
if (!workflowDef) return null;
|
|
143
145
|
return renderWorkflowEditor(user, segments[2], workflowDef);
|
|
144
146
|
}
|
|
147
|
+
if (normalized === '/admin/roles') {
|
|
148
|
+
const { getConfigEngineSync } = await import('@/lib/config-generator-engine.js');
|
|
149
|
+
const engine = getConfigEngineSync();
|
|
150
|
+
return renderRolesList(user, engine.getRoles());
|
|
151
|
+
}
|
|
152
|
+
if (normalized === '/admin/permissions') {
|
|
153
|
+
const { getConfigEngineSync } = await import('@/lib/config-generator-engine.js');
|
|
154
|
+
const engine = getConfigEngineSync();
|
|
155
|
+
const config = engine.getConfig();
|
|
156
|
+
return renderTemplateList(user, Object.keys(config.permission_templates || {}));
|
|
157
|
+
}
|
|
158
|
+
if (segments.length === 3 && segments[1] === 'permissions') {
|
|
159
|
+
const { getConfigEngineSync } = await import('@/lib/config-generator-engine.js');
|
|
160
|
+
const engine = getConfigEngineSync();
|
|
161
|
+
const config = engine.getConfig();
|
|
162
|
+
const roleActionsMap = config.permission_templates?.[segments[2]];
|
|
163
|
+
if (!roleActionsMap) return null;
|
|
164
|
+
const roleNames = Object.keys(engine.getRoles());
|
|
165
|
+
return renderPermissionMatrix(user, segments[2], roleNames, roleActionsMap);
|
|
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
|
+
}
|
|
145
181
|
return null;
|
|
146
182
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { page } from '@/ui/layout.js';
|
|
2
|
+
import { esc } from '@/ui/render-helpers.js';
|
|
3
|
+
|
|
4
|
+
const ACTIONS = ['list', 'view', 'create', 'edit', 'delete', 'archive', 'export', 'manage_settings'];
|
|
5
|
+
|
|
6
|
+
export function renderRolesList(user, roles) {
|
|
7
|
+
const sorted = Object.entries(roles).sort((a, b) => (a[1].hierarchy ?? 999) - (b[1].hierarchy ?? 999));
|
|
8
|
+
const rows = sorted.map(([name, def]) =>
|
|
9
|
+
`<tr data-row><td>${esc(name)}</td><td>${esc(def.label || name)}</td><td>${esc(String(def.hierarchy ?? '-'))}</td><td>${esc(def.permissions_scope || '-')}</td></tr>`
|
|
10
|
+
).join('') || '<tr><td colspan="4">No roles configured</td></tr>';
|
|
11
|
+
const content = `<div class="page-header"><h1 class="page-title">Roles</h1></div>
|
|
12
|
+
<div class="table-wrap"><table class="data-table"><thead><tr><th>Name</th><th>Label</th><th>Hierarchy</th><th>Scope</th></tr></thead><tbody>${rows}</tbody></table></div>
|
|
13
|
+
<div style="margin-top:16px"><a href="/admin/permissions" class="btn-ghost-clean">Edit Permission Templates</a></div>`;
|
|
14
|
+
return page(user, 'Roles | Thatcher', [{ href: '/admin/settings', label: 'Settings' }, { label: 'Roles' }], content);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function renderTemplateList(user, templateNames) {
|
|
18
|
+
const rows = templateNames.map(name =>
|
|
19
|
+
`<tr data-row data-navigate="/admin/permissions/${esc(name)}" style="cursor:pointer"><td>${esc(name)}</td></tr>`
|
|
20
|
+
).join('') || '<tr><td>No permission templates configured</td></tr>';
|
|
21
|
+
const content = `<div class="page-header"><h1 class="page-title">Permission Templates</h1></div>
|
|
22
|
+
<div class="table-wrap"><table class="data-table"><thead><tr><th>Name</th></tr></thead><tbody>${rows}</tbody></table></div>`;
|
|
23
|
+
return page(user, 'Permission Templates | Thatcher', [{ href: '/admin/settings', label: 'Settings' }, { href: '/admin/roles', label: 'Roles' }, { label: 'Permission Templates' }], content);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function renderPermissionMatrix(user, templateName, roleNames, roleActionsMap) {
|
|
27
|
+
const header = ACTIONS.map(a => `<th style="text-align:center">${esc(a)}</th>`).join('');
|
|
28
|
+
const rows = roleNames.map(role => {
|
|
29
|
+
const actions = roleActionsMap[role] || [];
|
|
30
|
+
const cells = ACTIONS.map(a =>
|
|
31
|
+
`<td style="text-align:center"><input type="checkbox" data-role="${esc(role)}" data-action="${esc(a)}"${actions.includes(a) ? ' checked' : ''}></td>`
|
|
32
|
+
).join('');
|
|
33
|
+
return `<tr><td>${esc(role)}</td>${cells}</tr>`;
|
|
34
|
+
}).join('');
|
|
35
|
+
|
|
36
|
+
const content = `<div class="page-header"><h1 class="page-title">Permissions: ${esc(templateName)}</h1></div>
|
|
37
|
+
<div class="table-wrap"><table class="data-table" id="perm-matrix"><thead><tr><th>Role</th>${header}</tr></thead><tbody>${rows}</tbody></table></div>
|
|
38
|
+
<div style="margin-top:16px">
|
|
39
|
+
<button type="button" class="btn-primary-clean" data-action="savePermissions" data-args='["${esc(templateName)}"]'>Save</button>
|
|
40
|
+
<span id="save-status" style="margin-left:12px;font-size:13px"></span>
|
|
41
|
+
</div>`;
|
|
42
|
+
|
|
43
|
+
const script = `(function(){
|
|
44
|
+
window.savePermissions=function(templateName){
|
|
45
|
+
var boxes=document.querySelectorAll('#perm-matrix input[type="checkbox"]');
|
|
46
|
+
var map={};
|
|
47
|
+
boxes.forEach(function(cb){
|
|
48
|
+
var role=cb.getAttribute('data-role');
|
|
49
|
+
var action=cb.getAttribute('data-action');
|
|
50
|
+
if(!map[role])map[role]=[];
|
|
51
|
+
if(cb.checked)map[role].push(action);
|
|
52
|
+
});
|
|
53
|
+
var status=document.getElementById('save-status');
|
|
54
|
+
status.textContent='Saving...';
|
|
55
|
+
fetch('/api/permission-template/'+templateName+'/update',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({roles:map})})
|
|
56
|
+
.then(function(r){return r.json().then(function(d){return {ok:r.ok,d:d}})})
|
|
57
|
+
.then(function(res){if(res.ok){status.textContent='Saved';status.style.color='var(--color-success,#22c55e)';setTimeout(function(){location.reload()},500)}else{status.textContent='Error: '+(res.d.error||'save failed');status.style.color='var(--color-danger,#ef4444)'}})
|
|
58
|
+
.catch(function(err){status.textContent='Error: '+err.message;status.style.color='var(--color-danger,#ef4444)'});
|
|
59
|
+
};
|
|
60
|
+
})();`;
|
|
61
|
+
|
|
62
|
+
return page(user, `Permissions: ${templateName} | Thatcher`, [{ href: '/admin/settings', label: 'Settings' }, { href: '/admin/roles', label: 'Roles' }, { href: '/admin/permissions', label: 'Permission Templates' }, { label: templateName }], content, [script]);
|
|
63
|
+
}
|
|
@@ -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
|
+
}
|