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
+4
View File
@@ -266,6 +266,10 @@
"recipientAdmin": "Admin (sofort gesendet)",
"emailTemplate": "E-Mail-Vorlagenschlüssel",
"webhookUrl": "Webhook-URL",
"webhookTarget": "Webhook",
"webhookNone": "— Konfigurierten Webhook wählen —",
"webhookInactive": "(inaktiv)",
"webhookHint": "Wird über die Webhook-Pipeline zugestellt (Signatur, Wiederholungen, SSRF-Prüfungen). Endpunkte unter Einstellungen → Webhooks verwalten.",
"condition": "Bedingung",
"exprField": "Feld",
"exprOp": "Operator",
+4
View File
@@ -266,6 +266,10 @@
"recipientAdmin": "Admin (sent immediately)",
"emailTemplate": "Email template key",
"webhookUrl": "Webhook URL",
"webhookTarget": "Webhook",
"webhookNone": "— Select a configured webhook —",
"webhookInactive": "(inactive)",
"webhookHint": "Delivered via the webhook pipeline (signing, retries, SSRF checks). Manage endpoints in Settings → Webhooks.",
"condition": "Condition",
"exprField": "Field",
"exprOp": "Operator",
@@ -9,10 +9,13 @@ import { useTranslation } from 'react-i18next';
type Cfg = Record<string, any>;
interface WebhookOption { id: number; name: string; active: boolean }
interface Props {
nodeType: string;
config: Cfg;
onChange: (next: Cfg) => void;
webhooks?: WebhookOption[];
}
const field = 'w-full px-2 py-1.5 rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 text-sm';
@@ -52,7 +55,7 @@ const Row: React.FC<{ label: string; children: React.ReactNode }> = ({ label, ch
<div><label className={lbl}>{label}</label>{children}</div>
);
export const NodeConfigPanel: React.FC<Props> = ({ nodeType, config, onChange }) => {
export const NodeConfigPanel: React.FC<Props> = ({ nodeType, config, onChange, webhooks = [] }) => {
const { t } = useTranslation();
const [showJson, setShowJson] = useState(false);
const [jsonText, setJsonText] = useState(JSON.stringify(config || {}, null, 2));
@@ -109,8 +112,16 @@ export const NodeConfigPanel: React.FC<Props> = ({ nodeType, config, onChange })
)}
{(nodeType === 'action' || nodeType === 'webhook') && (config.action === 'webhook' || nodeType === 'webhook') && (
<Row label={t('workflows.editor.webhookUrl', 'Webhook URL')}>
<input className={field} value={config.url || ''} onChange={(e) => set({ url: e.target.value })} placeholder="https://…" />
<Row label={t('workflows.editor.webhookTarget', 'Webhook')}>
<select className={field} value={config.webhookId ?? ''} onChange={(e) => set({ webhookId: e.target.value ? Number(e.target.value) : undefined })}>
<option value="">{t('workflows.editor.webhookNone', '— Select a configured webhook —')}</option>
{webhooks.map((w) => (
<option key={w.id} value={w.id}>{w.name}{w.active ? '' : ` ${t('workflows.editor.webhookInactive', '(inactive)')}`}</option>
))}
</select>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('workflows.editor.webhookHint', 'Delivered via the webhook pipeline (signing, retries, SSRF checks). Manage endpoints in Settings → Webhooks.')}
</p>
</Row>
)}
@@ -20,6 +20,7 @@ import '@xyflow/react/dist/style.css';
import dagre from '@dagrejs/dagre';
import { ArrowLeft, Save, Trash2, Wand2, Code } from 'lucide-react';
import { Button, Loading } from '../../../components/common';
import { api } from '../../../config/api';
import { useAdminDarkMode } from '../../../contexts/AdminDarkModeContext';
import { workflowsService, type WorkflowNodeType } from '../../../services/workflows.service';
import { NodeConfigPanel } from './NodeConfigPanel';
@@ -127,6 +128,13 @@ export const WorkflowEditorPage: React.FC = () => {
enabled: Number.isFinite(workflowId),
});
// Configured webhook subscriptions — the webhook node references one of these
// (the delivery then rides the webhook worker pipeline).
const { data: webhooks = [] } = useQuery({
queryKey: ['admin-webhooks'],
queryFn: async () => (await api.get<Array<{ id: number; name: string; active: boolean }>>('/admin/webhooks')).data,
});
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const [name, setName] = useState('');
@@ -366,6 +374,7 @@ export const WorkflowEditorPage: React.FC = () => {
nodeType={(selectedNode.data as any).nodeType}
config={(selectedNode.data as any).config || {}}
onChange={(cfg) => updateNodeConfig(selectedNode.id, cfg)}
webhooks={webhooks}
/>
</div>
)}