thatcher 1.0.60 → 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 +36 -1
- package/src/lib/webhook-engine.js +187 -0
- package/src/server/server.js +143 -0
- package/src/ui/page-handler-admin.js +15 -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();
|
|
@@ -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
|
@@ -122,6 +122,18 @@ 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
|
+
|
|
125
137
|
// Check if user has custom route for this
|
|
126
138
|
const userRoutePath = path.join(process.cwd(), 'app/api', ...parts, 'route.js');
|
|
127
139
|
const routeExists = await fileExists(userRoutePath);
|
|
@@ -351,6 +363,137 @@ async function handleGenericCrud(req, res, entity, id, action, thatcher, configE
|
|
|
351
363
|
|
|
352
364
|
const PERMISSION_ACTIONS = new Set(['list', 'view', 'create', 'edit', 'delete', 'archive', 'export', 'manage_settings']);
|
|
353
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
|
+
|
|
354
497
|
async function handleUpdatePermissionTemplate(req, res, templateName, thatcher, configEngineArg) {
|
|
355
498
|
const user = await resolveRequestUser(req);
|
|
356
499
|
if (!user) {
|
|
@@ -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
|
+
}
|