feat(workflows): route webhook node through the delivery pipeline (full Option 1)

Replaces the one-shot guarded POST with the maintainer's intended end-state: the
webhook node now references a CONFIGURED webhook subscription (Settings →
Webhooks) and enqueues a real webhook_deliveries row via
webhookService.enqueueForWebhook. Delivery then rides the existing worker
pipeline, inheriting — not reimplementing — per-delivery SSRF re-validation
(validateExternalUrl / GHSA-wmjx-pc37-272r), HMAC signing with the
subscription's secret, retry/backoff, and the deliveries audit log.

- webhookService.enqueueForWebhook(webhookId, eventType, data): enqueue for one
  active subscription, bypassing fire()'s event-type matching. No schema change.
- webhook action: config.webhookId; unset/missing/inactive → observable skip;
  dry-run does not enqueue. event_type = workflow.<trigger>.
- Editor: webhook node config is now a subscription dropdown (was a raw URL),
  fed by the admin webhooks list, with a hint pointing to Settings → Webhooks.
- EN/DE strings; test asserts enqueue + dry-run no-op + inactive skip.
This commit is contained in:
Luca
2026-06-25 18:44:27 +02:00
parent af7eea8b43
commit 675e41a2f7
7 changed files with 108 additions and 42 deletions
+33
View File
@@ -210,6 +210,38 @@ function parseJsonField(value, fallback) {
try { return JSON.parse(value) ?? fallback; } catch { return fallback; }
}
/**
* Enqueue a delivery for ONE specific active webhook subscription, bypassing the
* event-type subscription matching that `fire` does. Used by the workflow engine
* `webhook` action: the flow author picks a configured webhook (which carries
* the URL + signing secret + create-time URL validation), and the delivery then
* rides the SAME worker pipeline as every other webhook — per-delivery SSRF
* re-validation, HMAC signing, retries/backoff and the audit log, all for free.
* Never throws. Returns { enqueued, reason?, deliveryId? }.
*/
async function enqueueForWebhook(webhookId, eventType, data) {
try {
const w = await db('webhooks').where({ id: webhookId, active: true }).first();
if (!w) return { enqueued: false, reason: 'webhook not found or inactive' };
const now = new Date();
const deliveryUuid = crypto.randomUUID();
const envelope = { id: deliveryUuid, type: eventType, created_at: now.toISOString(), data };
await db('webhook_deliveries').insert({
webhook_id: w.id,
event_type: String(eventType).slice(0, 64),
payload: JSON.stringify(envelope),
attempt_count: 0,
status: 'pending',
next_retry_at: now,
created_at: now,
});
return { enqueued: true, webhookId: w.id, deliveryId: deliveryUuid };
} catch (err) {
logger.error(`[webhookService.enqueueForWebhook] failed for #${webhookId}: ${err.message}`);
return { enqueued: false, reason: err.message };
}
}
/**
* Canonical event sub-object for outbound webhooks (#341). Always returns
* the full key set so receivers don't have to handle "field missing vs
@@ -235,6 +267,7 @@ function buildEventSubject(input = {}) {
module.exports = {
fire,
enqueueForWebhook,
generateSecret,
signPayload,
verifySignature,