Root cause
AI change
feat(automations): visual flow builder, engine, and logs
Loading…
How AI contributed
Direct introductionWACRM is a self-hostable CRM template for WhatsApp. In 0.7.0 and earlier, the automation send_webhook action in src/lib/automations/engine.ts and its validation in src/lib/automations/validate.ts allowed an authenticated user with automation privileges to submit an arbitrary webhook URL that the server fetched without the existing isDeliverableUrl SSRF guard in src/lib/webhooks/ssrf.ts, allowing r
Root cause
feat(automations): visual flow builder, engine, and logs
Fix
fix(security): SSRF guard on automation send_webhook action
Code comparison
diff --git a/src/components/automations/automation-builder.tsx b/src/components/automations/automation-builder.tsxnew file mode 100644index 0000000000..7371e01403--- /dev/null+++ b/src/components/automations/automation-builder.tsx@@ -0,0 +1,1144 @@+"use client"++import { useState } from "react"+import { useRouter } from "next/navigation"+import { toast } from "sonner"+import {+ ArrowLeft,+ ChevronDown,+ Plus,+ Trash2,+ GripVertical,+ MessageSquare,+ FileText,+ Tag,+ TagIcon,+ UserCheck,+ PencilLine,+ Briefcase,+ Hourglass,+ GitBranch,+ Webhook,+ CircleSlash,+ Zap,+ Loader2,+ ArrowDown,+ ArrowUp,+} from "lucide-react"++import { Button } from "@/components/ui/button"+import { Input } from "@/components/ui/input"+import { Textarea } from "@/components/ui/textarea"+import { Switch } from "@/components/ui/switch"+import {+ DropdownMenu,+ DropdownMenuContent,+ DropdownMenuItem,+ DropdownMenuTrigger,+} from "@/components/ui/dropdown-menu"+import type {+ AutomationStepType,+ AutomationTriggerType,+ KeywordMatchTriggerConfig,+} from "@/types"+import { cn } from "@/lib/utils"++// ------------------------------------------------------------+// Types (builder-local — mirror the flattened rows we POST)+// ------------------------------------------------------------++export interface BuilderStep {+ /** Client id; the API assigns real UUIDs server-side. */+ cid: string+ step_type: AutomationStepType+ step_config: Record<string, unknown>+ branches?: { yes: BuilderStep[]; no: BuilderStep[] }+}++export interface BuilderInitial {+ id?: string+ name: string+ description: string+ trigger_type: AutomationTriggerType+ trigger_config: Record<string, unknown>+ is_active: boolean+ steps: BuilderStep[]+}++// ------------------------------------------------------------+// Step metadata — one source of truth for icon + label + border color+// ------------------------------------------------------------++interface StepMeta {+ label: string+ icon: typeof Zap+ /** Left-border accent color per spec. */+ border: string+}++const STEP_META: Record<AutomationStepType, StepMeta> = {+ send_message: { label: "Send Message", icon: MessageSquare, border: "border-l-emerald-500" },+ send_template: { label: "Send Template", icon: FileText, border: "border-l-emerald-500" },+ add_tag: { label: "Add Tag", icon: Tag, border: "border-l-emerald-500" },+ remove_tag: { label: "Remove Tag", icon: TagIcon, border: "border-l-emerald-500" },+ assign_conversation: { label: "Assign Conversation", icon: UserCheck, border: "border-l-emerald-500" },+ update_contact_field: { label: "Update Contact Field", icon: PencilLine, border: "border-l-emerald-500" },+ create_deal: { label: "Create Deal", icon: Briefcase, border: "border-l-emerald-500" },+ wait: { label: "Wait", icon: Hourglass, border: "border-l-slate-500" },+ condition: { label: "Condition (If/Else)", icon: GitBranch, border: "border-l-amber-500" },+ send_webhook: { label: "Send Webhook", icon: Webhook, border: "border-l-emerald-500" },+ close_conversation: { label: "Close Conversation", icon: CircleSlash, border: "border-l-emerald-500" },+}++const ADDABLE_STEPS: AutomationStepType[] = [+ "send_message",+ "send_template",+ "add_tag",+ "remove_tag",+ "assign_conversation",+ "update_contact_field",+ "create_deal",+ "wait",+ "condition",+ "send_webhook",+ "close_conversation",+]++const TRIGGER_OPTIONS: { value: AutomationTriggerType; label: string; hint: string }[] = [+ { value: "new_message_received", label: "New Message Received", hint: "Any incoming message" },+ { value: "keyword_match", label: "Keyword Match", hint: "Message contains specific keyword(s)" },+ { value: "new_contact_created", label: "New Contact Created", hint: "When a contact is auto-created" },+ { value: "conversation_assigned", label: "Conversation Assigned", hint: "When assigned to an agent" },+ { value: "tag_added", label: "Tag Added", hint: "When a tag is added to a contact" },+ { value: "time_based", label: "Time-Based", hint: "On a recurring schedule" },+]++function cid(): string {+ return (+ "c_" ++ (typeof crypto !== "undefined" && "randomUUID" in crypto+ ? crypto.randomUUID()+ : Math.random().toString(36).slice(2) + Date.now().toString(36))+ )+}++function blankConfig(type: AutomationStepType): Record<string, unknown> {+ switch (type) {+ case "send_message":+ return { text: "" }+ case "send_template":+ return { template_name: "", language: "en_US" }+ case "add_tag":+ case "remove_tag":+ return { tag_id: "" }+ case "assign_conversation":+ return { mode: "round_robin" }+ case "update_contact_field":+ return { field: "name", value: "" }+ case "create_deal":+ return { pipeline_id: "", stage_id: "", title: "", value: 0 }+ case "wait":+ return { amount: 1, unit: "hours" }+ case "condition":+ return { subject: "tag_presence", operand: "", value: "" }+ case "send_webhook":+ return { url: "", headers: {}, body_template: "" }+ case "close_conversation":+ return {}+ default:+ return {}+ }+}++// ------------------------------------------------------------+// Main builder component+// ------------------------------------------------------------++export function AutomationBuilder({ initial }: { initial: BuilderInitial }) {+ const router = useRouter()+ const isEditing = !!initial.id+ const [state, setState] = useState<BuilderInitial>(initial)+ const [saving, setSaving] = useState(false)+ const [expandedId, setExpandedId] = useState<string | null>(null)++ function patchTop<K extends keyof BuilderInitial>(key: K, value: BuilderInitial[K]) {+ setState((s) => ({ ...s, [key]: value }))+ }++ // --- Step tree mutations (immutable) ---++ function updateStep(path: StepPath, updater: (s: BuilderStep) => BuilderStep) {+ setState((s) => ({ ...s, steps: mapAtPath(s.steps, path, updater) }))+ }++ function addStepAt(parent: ParentScope, index: number, type: AutomationStepType) {+ const node: BuilderStep = {+ cid: cid(),+ step_type: type,+ step_config: blankConfig(type),+ branches: type === "condition" ? { yes: [], no: [] } : undefined,+ }+ setState((s) => ({ ...s, steps: insertAt(s.steps, parent, index, node) }))+ setExpandedId(node.cid)+ }++ function deleteStepAt(path: StepPath) {+ setState((s) => ({ ...s, steps: removeAt(s.steps, path) }))+ }++ function moveStepAt(path: StepPath, direction: -1 | 1) {+ setState((s) => ({ ...s, steps: moveAt(s.steps, path, direction) }))+ }++ async function save() {+ setSaving(true)+ try {+ const payload = {+ name: state.name || "Untitled automation",+ description: state.description || null,+ trigger_type: state.trigger_type,+ trigger_config: state.trigger_config,+ is_active: state.is_active,+ steps: toApiSteps(state.steps),+ }++ const res = isEditing+ ? await fetch(`/api/automations/${initial.id}`, {+ method: "PATCH",+ headers: { "content-type": "application/json" },+ body: JSON.stringify(payload),+ })+ : await fetch(`/api/automations`, {+ method: "POST",+ headers: { "content-type": "application/json" },+ body: JSON.stringify(payload),+ })++ const body = await res.json().catch(() => ({}))+ if (!res.ok) {+ toast.error(body?.error ?? "Save failed")+ return+ }+ toast.success(isEditing ? "Automation saved" : "Automation created")+ if (!isEditing && body?.automation?.id) {+ router.replace(`/automations/${body.automation.id}/edit`)+ }+ } finally {+ setSaving(false)+ }+ }++ return (+ <div className="fixed inset-0 flex flex-col bg-slate-950">+ {/* Top bar */}+ <header className="flex flex-shrink-0 items-center gap-3 border-b border-slate-800 bg-slate-900/80 px-4 py-3">+ <button+ type="button"+ onClick={() => router.push("/automations")}+ className="flex h-8 w-8 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-slate-800 hover:text-white"+ aria-label="Back to automations"+ >+ <ArrowLeft className="h-4 w-4" />+ </button>+ <input+ value={state.name}+ onChange={(e) => patchTop("name", e.target.value)}+ placeholder="Untitled automation"+ className="flex-1 rounded-md bg-transparent px-2 py-1 text-base font-semibold text-white placeholder:text-slate-500 focus:bg-slate-800 focus:outline-none"+ />+ <div className="flex items-center gap-2 text-xs text-slate-400">+ <span>Active</span>+ <Switch+ checked={state.is_active}+ onCheckedChange={(v) => patchTop("is_active", !!v)}+ />+ </div>+ <Button+ onClick={save}+ disabled={saving}+ className="bg-emerald-600 text-white hover:bg-emerald-700"+ >+ {saving ? <Loader2 className="h-4 w-4 animate-spin" /> : null}+ {isEditing ? "Save" : "Save Draft"}+ </Button>+ </header>++ {/* Canvas */}+ <div className="relative flex-1 overflow-y-auto">+ <div className="absolute inset-0 bg-[radial-gradient(circle,#1e293b_1px,transparent_1px)] [background-size:20px_20px] pointer-events-none" />+ <div className="relative mx-auto flex max-w-2xl flex-col items-center gap-0 px-4 py-10">+ <TriggerCard+ type={state.trigger_type}+ config={state.trigger_config}+ onTypeChange={(t) => patchTop("trigger_type", t)}+ onConfigChange={(c) => patchTop("trigger_config", c)}+ />+ <StepList+ steps={state.steps}+ parentPath={[]}+ expandedId={expandedId}+ setExpandedId={setExpandedId}+ updateStep={updateStep}+ addStepAt={addStepAt}+ deleteStepAt={deleteStepAt}+ moveStepAt={moveStepAt}+ />+ </div>+ </div>+ </div>+ )+}++// ------------------------------------------------------------+// Trigger card+// ------------------------------------------------------------++function TriggerCard({+ type,+ config,+ onTypeChange,+ onConfigChange,+}: {+ type: AutomationTriggerType+ config: Record<string, unknown>+ onTypeChange: (t: AutomationTriggerType) => void+ onConfigChange: (c: Record<string, unknown>) => void+}) {+ const [open, setOpen] = useState(false)+ return (+ <div className="z-10 w-80">+ <div className="rounded-lg border border-slate-800 border-l-4 border-l-blue-500 bg-slate-900 shadow-lg">+ <button+ type="button"+ onClick={() => setOpen((v) => !v)}+ className="flex w-full items-center gap-3 px-4 py-3 text-left"+ >+ <div className="flex h-8 w-8 items-center justify-center rounded-md bg-blue-500/10 text-blue-400">+ <Zap className="h-4 w-4" />+ </div>+ <div className="min-w-0 flex-1">+ <div className="text-[11px] uppercase tracking-wide text-blue-300">Trigger</div>+ <div className="truncate text-sm font-medium text-white">+ {TRIGGER_OPTIONS.find((o) => o.value === type)?.label ?? type}+ </div>+ </div>+ <ChevronDown+ className={cn("h-4 w-4 text-slate-400 transition-transform", open && "rotate-180")}+ />+ </button>+ {open && (+ <div className="space-y-3 border-t border-slate-800 px-4 py-3">+ <div>+ <label className="mb-1 block text-xs font-medium text-slate-400">+ Trigger type+ </label>+ <select+ value={type}+ onChange={(e) => onTypeChange(e.target.value as AutomationTriggerType)}+ className="w-full rounded-md border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm text-white focus:border-emerald-500 focus:outline-none"+ >+ {TRIGGER_OPTIONS.map((o) => (+ <option key={o.value} value={o.value}>+ {o.label}+ </option>+ ))}+ </select>+ <p className="mt-1 text-[11px] text-slate-500">+ {TRIGGER_OPTIONS.find((o) => o.value === type)?.hint}+ </p>+ </div>+ {type === "keyword_match" && (+ <KeywordMatchConfig+ config={config as unknown as KeywordMatchTriggerConfig}+ onChange={onConfigChange}+ />+ )}+ {type === "tag_added" && (+ <Input+ placeholder="Tag id"+ value={(config.tag_id as string) ?? ""}+ onChange={(e) =>+ onConfigChange({ ...config, tag_id: e.target.value })+ }+ className="bg-slate-800 text-white"+ />+ )}+ {type === "time_based" && (+ <Input+ placeholder="Cron expression or HH:mm"+ value={(config.schedule as string) ?? ""}+ onChange={(e) =>+ onConfigChange({ ...config, schedule: e.target.value })+ }+ className="bg-slate-800 text-white"+ />+ )}+ </div>+ )}+ </div>+ </div>+ )+}++function KeywordMatchConfig({+ config,+ onChange,+}: {+ config: KeywordMatchTriggerConfig+ onChange: (c: Record<string, unknown>) => void+}) {+ const keywords = config?.keywords ?? []+ return (+ <div className="space-y-2">+ <div>+ <label className="mb-1 block text-xs font-medium text-slate-400">+ Keywords (comma-separated)+ </label>+ <Input+ value={keywords.join(", ")}+ onChange={(e) =>+ onChange({+ ...config,+ keywords: e.target.value+ .split(",")+ .map((s) => s.trim())+ .filter(Boolean),+ })+ }+ className="bg-slate-800 text-white"+ />+ </div>+ <div>+ <label className="mb-1 block text-xs font-medium text-slate-400">+ Match type+ </label>+ <select+ value={config?.match_type ?? "contains"}+ onChange={(e) => onChange({ ...config, match_type: e.target.value as "exact" | "contains" })}+ className="w-full rounded-md border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm text-white focus:outline-none"+ >+ <option value="contains">Contains</option>+ <option value="exact">Exact</option>+ </select>+ </div>+ </div>+ )+}++// ------------------------------------------------------------+// Step list + card + connectors+// ------------------------------------------------------------++type ParentScope =+ | { kind: "root" }+ | { kind: "branch"; parentCid: string; branch: "yes" | "no" }++type StepPath = (+ | { kind: "root"; index: number }+ | { kind: "branch"; parentCid: string; branch: "yes" | "no"; index: number }+)[]++interface StepListProps {+ steps: BuilderStep[]+ parentPath: StepPath+ expandedId: string | null+ setExpandedId: (id: string | null) => void+ updateStep: (path: StepPath, updater: (s: BuilderStep) => BuilderStep) => void+ addStepAt: (parent: ParentScope, index: number, type: AutomationStepType) => void+ deleteStepAt: (path: StepPath) => void+ moveStepAt: (path: StepPath, direction: -1 | 1) => void+}++function StepList(props: StepListProps) {+ const { steps, parentPath, ...rest } = props+ const parentScope: ParentScope =+ parentPath.length === 0+ ? { kind: "root" }+ : (() => {+ const last = parentPath[parentPath.length - 1]+ if (last.kind !== "branch") return { kind: "root" } as const+ return { kind: "branch", parentCid: last.parentCid, branch: last.branch } as const+ })()++ return (+ <div className="flex flex-col items-center">+ <AddButton onPick={(t) => props.addStepAt(parentScope, 0, t)} />+ {steps.map((step, idx) => (+ <StepRenderer+ key={step.cid}+ step={step}+ index={idx}+ total={steps.length}+ parentScope={parentScope}+ parentPath={parentPath}+ {...rest}+ />+ ))}+ </div>+ )+}++function StepRenderer({+ step,+ index,+ total,+ parentScope,+ parentPath,+ ...props+}: {+ step: BuilderStep+ index: number+ total: number+ parentScope: ParentScope+ parentPath: StepPath+} & Omit<StepListProps, "steps" | "parentPath">) {+ const path: StepPath = [+ ...parentPath,+ parentScope.kind === "root"+ ? { kind: "root", index }+ : { kind: "branch", parentCid: parentScope.parentCid, branch: parentScope.branch, index },+ ]+ const meta = STEP_META[step.step_type]+ const Icon = meta.icon+ const expanded = props.expandedId === step.cid+ const isCondition = step.step_type === "condition"+ const width = isCondition ? "w-[400px]" : "w-80"++ return (+ <>+ <div className={cn("z-10 flex flex-col", width)}>+ <div+ className={cn(+ "rounded-lg border border-slate-800 border-l-4 bg-slate-900 shadow-lg",+ meta.border,+ )}+ >+ <button+ type="button"+ onClick={() => props.setExpandedId(expanded ? null : step.cid)}+ className="flex w-full items-center gap-3 px-4 py-3 text-left"+ >+ <GripVertical className="h-4 w-4 flex-shrink-0 text-slate-600" aria-hidden />+ <div className="flex h-8 w-8 items-center justify-center rounded-md bg-slate-800 text-slate-300">+ <Icon className="h-4 w-4" />+ </div>+ <div className="min-w-0 flex-1">+ <div className="text-[11px] uppercase tracking-wide text-slate-400">+ {isCondition ? "Condition" : step.step_type === "wait" ? "Wait" : "Action"}+ </div>+ <div className="truncate text-sm font-medium text-white">{meta.label}</div>+ <div className="truncate text-[11px] text-slate-500">{previewFor(step)}</div>+ </div>+ <ChevronDown+ className={cn("h-4 w-4 text-slate-400 transition-transform", expanded && "rotate-180")}+ />+ </button>+ {expanded && (+ <div className="border-t border-slate-800 px-4 py-3">+ <StepEditor+ step={step}+ onChange={(next) => props.updateStep(path, => next)}+ />+ <div className="mt-3 flex items-center justify-between gap-2 border-t border-slate-800 pt-3">+ <div className="flex gap-1">+ <Button+ variant="ghost"+ size="icon"+ disabled={index === 0}+ aria-label="Move up"+ onClick={() => props.moveStepAt(path, -1)}+ >+ <ArrowUp className="h-4 w-4" />+ </Button>+ <Button+ variant="ghost"+ size="icon"+ disabled={index === total - 1}+ aria-label="Move down"+ onClick={() => props.moveStepAt(path, 1)}+ >+ <ArrowDown className="h-4 w-4" />+ </Button>+ </div>+ <Button+ variant="destructive"+ size="sm"+ onClick={() => props.deleteStepAt(path)}+ >+ <Trash2 className="h-3.5 w-3.5" />+ Delete+ </Button>+ </div>+ </div>+ )}+ </div>++ {isCondition && (+ <ConditionBranches step={step} parentPath={path} {...props} />+ )}+ </div>++ <AddButton+ onPick={(t) => props.addStepAt(parentScope, index + 1, t)}+ />+ </>+ )+}++function ConditionBranches({+ step,+ parentPath,+ ...props+}: {+ step: BuilderStep+ parentPath: StepPath+} & Omit<StepListProps, "steps" | "parentPath">) {+ const yes = step.branches?.yes ?? []+ const no = step.branches?.no ?? []+ // Build the child scope by appending a branch marker. The scope the+ // StepList uses is driven by the LAST element of parentPath, so the+ // tail's `index` doesn't matter — it's replaced per child during walks.+ const yesPath: StepPath = [+ ...parentPath,+ { kind: "branch", parentCid: step.cid, branch: "yes", index: 0 },+ ]+ const noPath: StepPath = [+ ...parentPath,+ { kind: "branch", parentCid: step.cid, branch: "no", index: 0 },+ ]+ return (+ <div className="mt-3 grid grid-cols-2 gap-3">+ <BranchColumn label="Yes" color="text-emerald-400">+ <StepList {...props} steps={yes} parentPath={yesPath} />+ </BranchColumn>+ <BranchColumn label="No" color="text-rose-400">+ <StepList {...props} steps={no} parentPath={noPath} />+ </BranchColumn>+ </div>+ )+}++function BranchColumn({+ label,+ color,+ children,+}: {+ label: string+ color: string+ children: React.ReactNode+}) {+ return (+ <div className="flex flex-col items-center">+ <div className={cn("mb-2 text-[11px] font-semibold uppercase", color)}>{label}</div>+ {children}+ </div>+ )+}++function AddButton({ onPick }: { onPick: (t: AutomationStepType) => void }) {+ return (+ <div className="relative flex flex-col items-center">+ <div className="h-4 w-[2px] bg-slate-700" aria-hidden />+ <DropdownMenu>+ <DropdownMenuTrigger+ className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-dashed border-slate-700 bg-slate-950 text-slate-400 transition-colors hover:border-emerald-500 hover:bg-emerald-500/10 hover:text-emerald-400 data-[popup-open]:border-emerald-500 data-[popup-open]:bg-emerald-500/20 data-[popup-open]:text-emerald-400"+ aria-label="Add step"+ >+ <Plus className="h-4 w-4" />+ </DropdownMenuTrigger>+ <DropdownMenuContent className="max-h-80 overflow-y-auto border-slate-700 bg-slate-900">+ {ADDABLE_STEPS.map((t) => {+ const Icon = STEP_META[t].icon+ return (+ <DropdownMenuItem key={t} onClick={() => onPick(t)}>+ <Icon className="h-4 w-4" />+ {STEP_META[t].label}+ </DropdownMenuItem>+ )+ })}+ </DropdownMenuContent>+ </DropdownMenu>+ <div className="h-4 w-[2px] bg-slate-700" aria-hidden />+ </div>+ )+}++// ------------------------------------------------------------+// Per-step config editor+// ------------------------------------------------------------++function StepEditor({+ step,+ onChange,+}: {+ step: BuilderStep+ onChange: (s: BuilderStep) => void+}) {+ const cfg = step.step_config+ const set = (patch: Record<string, unknown>) =>+ onChange({ ...step, step_config: { ...cfg, ...patch } })++ switch (step.step_type) {+ case "send_message":+ return (+ <FieldBlock label="Message text">+ <Textarea+ value={(cfg.text as string) ?? ""}+ onChange={(e) => set({ text: e.target.value })}+ placeholder="Hi! Thanks for reaching out…"+ className="min-h-24 bg-slate-800 text-white"+ />+ </FieldBlock>+ )+ case "send_template":+ return (+ <>+ <FieldBlock label="Template name">+ <Input+ value={(cfg.template_name as string) ?? ""}+ onChange={(e) => set({ template_name: e.target.value })}+ className="bg-slate-800 text-white"+ />+ </FieldBlock>+ <FieldBlock label="Language">+ <Input+ value={(cfg.language as string) ?? ""}+ onChange={(e) => set({ language: e.target.value })}+ className="bg-slate-800 text-white"+ />+ </FieldBlock>+ </>+ )+ case "add_tag":+ case "remove_tag":+ return (+ <FieldBlock label="Tag id">+ <Input+ value={(cfg.tag_id as string) ?? ""}+ onChange={(e) => set({ tag_id: e.target.value })}+ className="bg-slate-800 text-white"+ />+ </FieldBlock>+ )+ case "assign_conversation":+ return (+ <>+ <FieldBlock label="Mode">+ <select+ value={(cfg.mode as string) ?? "round_robin"}+ onChange={(e) => set({ mode: e.target.value })}+ className="w-full rounded-md border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm text-white"+ >+ <option value="round_robin">Round-robin</option>+ <option value="specific">Specific agent</option>+ </select>+ </FieldBlock>+ {cfg.mode === "specific" && (+ <FieldBlock label="Agent id">+ <Input+ value={(cfg.agent_id as string) ?? ""}+ onChange={(e) => set({ agent_id: e.target.value })}+ className="bg-slate-800 text-white"+ />+ </FieldBlock>+ )}+ </>+ )+ case "update_contact_field":+ return (+ <>+ <FieldBlock label="Field">+ <select+ value={(cfg.field as string) ?? "name"}+ onChange={(e) => set({ field: e.target.value })}+ className="w-full rounded-md border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm text-white"+ >+ <option value="name">Name</option>+ <option value="email">Email</option>+ <option value="company">Company</option>+ </select>+ </FieldBlock>+ <FieldBlock label="Value">+ <Input+ value={(cfg.value as string) ?? ""}+ onChange={(e) => set({ value: e.target.value })}+ className="bg-slate-800 text-white"+ />+ </FieldBlock>+ </>+ )+ case "create_deal":+ return (+ <>+ <FieldBlock label="Pipeline id">+ <Input+ value={(cfg.pipeline_id as string) ?? ""}+ onChange={(e) => set({ pipeline_id: e.target.value })}+ className="bg-slate-800 text-white"+ />+ </FieldBlock>+ <FieldBlock label="Stage id">+ <Input+ value={(cfg.stage_id as string) ?? ""}+ onChange={(e) => set({ stage_id: e.target.value })}+ className="bg-slate-800 text-white"+ />+ </FieldBlock>+ <FieldBlock label="Title">+ <Input+ value={(cfg.title as string) ?? ""}+ onChange={(e) => set({ title: e.target.value })}+ className="bg-slate-800 text-white"+ />+ </FieldBlock>+ <FieldBlock label="Value">+ <Input+ type="number"+ value={(cfg.value as number) ?? 0}+ onChange={(e) => set({ value: Number(e.target.value) })}+ className="bg-slate-800 text-white"+ />+ </FieldBlock>+ </>+ )+ case "wait":+ return (+ <div className="grid grid-cols-2 gap-2">+ <FieldBlock label="Amount">+ <Input+ type="number"+ min={1}+ value={(cfg.amount as number) ?? 1}+ onChange={(e) => set({ amount: Math.max(1, Number(e.target.value)) })}+ className="bg-slate-800 text-white"+ />+ </FieldBlock>+ <FieldBlock label="Unit">+ <select+ value={(cfg.unit as string) ?? "hours"}+ onChange={(e) => set({ unit: e.target.value })}+ className="w-full rounded-md border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm text-white"+ >+ <option value="minutes">Minutes</option>+ <option value="hours">Hours</option>+ <option value="days">Days</option>+ </select>+ </FieldBlock>+ </div>+ )+ case "condition":+ return (+ <>+ <FieldBlock label="Subject">+ <select+ value={(cfg.subject as string) ?? "tag_presence"}+ onChange={(e) => set({ subject: e.target.value })}+ className="w-full rounded-md border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm text-white"+ >+ <option value="tag_presence">Tag presence</option>+ <option value="contact_field">Contact field</option>+ <option value="message_content">Message content</option>+ <option value="time_of_day">Time of day</option>+ </select>+ </FieldBlock>+ <FieldBlock label="Operand">+ <Input+ placeholder={+ cfg.subject === "time_of_day"+ ? "HH:mm-HH:mm"+ : cfg.subject === "contact_field"+ ? "name / email / company"+ : cfg.subject === "tag_presence"+ ? "tag id"+ : ""+ }+ value={(cfg.operand as string) ?? ""}+ onChange={(e) => set({ operand: e.target.value })}+ className="bg-slate-800 text-white"+ />+ </FieldBlock>+ {(cfg.subject === "contact_field" || cfg.subject === "message_content") && (+ <FieldBlock label="Value">+ <Input+ value={(cfg.value as string) ?? ""}+ onChange={(e) => set({ value: e.target.value })}+ className="bg-slate-800 text-white"+ />+ </FieldBlock>+ )}+ </>+ )+ case "send_webhook":+ return (+ <>+ <FieldBlock label="URL">+ <Input+ value={(cfg.url as string) ?? ""}+ onChange={(e) => set({ url: e.target.value })}+ className="bg-slate-800 text-white"+ />+ </FieldBlock>+ <FieldBlock label="Body template (JSON)">+ <Textarea+ value={(cfg.body_template as string) ?? ""}+ onChange={(e) => set({ body_template: e.target.value })}+ className="min-h-20 bg-slate-800 font-mono text-xs text-white"+ />+ </FieldBlock>+ </>+ )+ case "close_conversation":+ return (+ <p className="text-xs text-slate-400">+ Sets the conversation status to "closed". No configuration needed.+ </p>+ )+ default:+ return null+ }+}++function FieldBlock({+ label,+ children,+}: {+ label: string+ children: React.ReactNode+}) {+ return (+ <div className="mb-2 last:mb-0">+ <label className="mb-1 block text-xs font-medium text-slate-400">{label}</label>+ {children}+ </div>+ )+}++function previewFor(step: BuilderStep): string {+ switch (step.step_type) {+ case "send_message":+ return (step.step_config.text as string) || "no text yet"+ case "send_template":+ return (step.step_config.template_name as string) || "pick a template"+ case "wait":+ return `${step.step_config.amount ?? "?"} ${step.step_config.unit ?? ""}`+ case "condition":+ return `when ${step.step_config.subject ?? "?"}`+ case "send_webhook":+ return (step.step_config.url as string) || "no url"+ default:+ return ""+ }+}++// ------------------------------------------------------------+// Tree mutation helpers+// ------------------------------------------------------------++function insertAt(+ steps: BuilderStep[],+ parent: ParentScope,+ index: number,+ node: BuilderStep,+): BuilderStep[] {+ if (parent.kind === "root") {+ const copy = [...steps]+ copy.splice(index, 0, node)+ return copy+ }+ return steps.map((s) => {+ if (s.cid !== parent.parentCid || !s.branches) return s+ const list = [...s.branches[parent.branch]]+ list.splice(index, 0, node)+ return { ...s, branches: { ...s.branches, [parent.branch]: list } }+ })+}++function mapAtPath(+ steps: BuilderStep[],+ path: StepPath,+ updater: (s: BuilderStep) => BuilderStep,+): BuilderStep[] {+ if (path.length === 0) return steps+ const head = path[0]+ const rest = path.slice(1)++ if (head.kind === "root") {+ return steps.map((s, i) => {+ if (i !== head.index) return s+ return rest.length === 0+ ? updater(s)+ : { ...s, branches: walkBranches(s.branches, rest, updater) }+ })+ }+ return steps.map((s) => {+ if (s.cid !== head.parentCid || !s.branches) return s+ const bucket = s.branches[head.branch]+ const updated = bucket.map((child, i) => {+ if (i !== head.index) return child+ return rest.length === 0+ ? updater(child)+ : { ...child, branches: walkBranches(child.branches, rest, updater) }+ })+ return { ...s, branches: { ...s.branches, [head.branch]: updated } }+ })+}++function walkBranches(+ branches: BuilderStep["branches"],+ path: StepPath,+ updater: (s: BuilderStep) => BuilderStep,+): BuilderStep["branches"] {+ if (!branches) return branches+ const head = path[0]+ if (head.kind !== "branch") return branches+ const bucket = branches[head.branch]+ const rest = path.slice(1)+ const updated = bucket.map((child, i) => {+ if (i !== head.index) return child+ return rest.length === 0+ ? updater(child)+ : { ...child, branches: walkBranches(child.branches, rest, updater) }+ })+ return { ...branches, [head.branch]: updated }+}++function removeAt(steps: BuilderStep[], path: StepPath): BuilderStep[] {+ if (path.length === 0) return steps+ const head = path[0]+ const rest = path.slice(1)+ if (head.kind === "root") {+ if (rest.length === 0) return steps.filter((_, i) => i !== head.index)+ return steps.map((s, i) =>+ i !== head.index ? s : { ...s, branches: removeFromBranches(s.branches, rest) },+ )+ }+ return steps.map((s) => {+ if (s.cid !== head.parentCid || !s.branches) return s+ const bucket = s.branches[head.branch]+ const next =+ rest.length === 0+ ? bucket.filter((_, i) => i !== head.index)+ : bucket.map((child, i) =>+ i !== head.index+ ? child+ : { ...child, branches: removeFromBranches(child.branches, rest) },+ )+ return { ...s, branches: { ...s.branches, [head.branch]: next } }+ })+}++function removeFromBranches(+ branches: BuilderStep["branches"],+ path: StepPath,+): BuilderStep["branches"] {+ if (!branches) return branches+ const head = path[0]+ if (head.kind !== "branch") return branches+ const rest = path.slice(1)+ const bucket = branches[head.branch]+ const next =+ rest.length === 0+ ? bucket.filter((_, i) => i !== head.index)+ : bucket.map((child, i) =>+ i !== head.index+ ? child+ : { ...child, branches: removeFromBranches(child.branches, rest) },+ )+ return { ...branches, [head.branch]: next }+}++function moveAt(+ steps: BuilderStep[],+ path: StepPath,+ direction: -1 | 1,+): BuilderStep[] {+ if (path.length === 0) return steps+ const head = path[0]+ const rest = path.slice(1)+ const swap = <T,>(arr: T[], i: number) => {+ const j = i + direction+ if (j < 0 || j >= arr.length) return arr+ const copy = [...arr]+ ;[copy[i], copy[j]] = [copy[j], copy[i]]+ return copy+ }+ if (head.kind === "root") {+ if (rest.length === 0) return swap(steps, head.index)+ return steps.map((s, i) =>+ i !== head.index ? s : { ...s, branches: moveInBranches(s.branches, rest, direction) },+ )+ }+ return steps.map((s) => {+ if (s.cid !== head.parentCid || !s.branches) return s+ const bucket = s.branches[head.branch]+ const next = rest.length === 0 ? swap(bucket, head.index) : bucket+ return { ...s, branches: { ...s.branches, [head.branch]: next } }+ })+}++function moveInBranches(+ branches: BuilderStep["branches"],+ path: StepPath,+ direction: -1 | 1,+): BuilderStep["branches"] {+ if (!branches) return branches+ const head = path[0]+ if (head.kind !== "branch") return branches+ const rest = path.slice(1)+ const bucket = branches[head.branch]+ const swap = <T,>(arr: T[], i: number) => {+ const j = i + direction+ if (j < 0 || j >= arr.length) return arr+ const copy = [...arr]+ ;[copy[i], copy[j]] = [copy[j], copy[i]]+ return copy+ }+ const next = rest.length === 0 ? swap(bucket, head.index) : bucket+ return { ...branches, [head.branch]: next }+}++// ------------------------------------------------------------+// Serialize builder tree → API payload (flattened shape)+// ------------------------------------------------------------++interface ApiStep {+ step_type: string+ step_config: Record<string, unknown>+ branches?: { yes?: ApiStep[]; no?: ApiStep[] }+}++export function toApiSteps(steps: BuilderStep[]): ApiStep[] {+ return steps.map((s) => ({+ step_type: s.step_type,+ step_config: s.step_config,+ branches: s.branches+ ? { yes: toApiSteps(s.branches.yes), no: toApiSteps(s.branches.no) }+ : undefined,+ }))+}++/**+ * Convert server-returned step tree (from loadStepsTree) into the+ * builder-local shape with client ids.+ */+export interface ServerStepNode {+ id: string+ step_type: string+ step_config: Record<string, unknown>+ branches: { yes: ServerStepNode[]; no: ServerStepNode[] }+}++export function fromServerSteps(nodes: ServerStepNode[]): BuilderStep[] {+ return nodes.map((n) => ({+ cid: cid(),+ step_type: n.step_type as AutomationStepType,+ step_config: n.step_config ?? {},+ branches:+ n.step_type === "condition"+ ? {+ yes: fromServerSteps(n.branches?.yes ?? []),+ no: fromServerSteps(n.branches?.no ?? []),+ }+ : undefined,+ }))+}diff --git a/src/lib/automations/steps-tree.ts b/src/lib/automations/steps-tree.tsnew file mode 100644index 0000000000..e012ffd2c5--- /dev/null+++ b/src/lib/automations/steps-tree.ts@@ -0,0 +1,162 @@+import { supabaseAdmin } from './admin-client'++// ------------------------------------------------------------+// Builder payload → flat rows for automation_steps.+// Root steps arrive in order. A Condition step carries its children+// under `branches: { yes: [...], no: [...] }`. We walk the tree and+// assign stable UUIDs so parent_step_id references resolve in a+// single INSERT.+// ------------------------------------------------------------++export interface BuilderStepInput {+ id?: string+ step_type: string+ step_config: Record<string, unknown>+ branches?: { yes?: BuilderStepInput[]; no?: BuilderStepInput[] }+ // Legacy flat form (from template seeds):+ branch?: 'yes' | 'no' | null+ parent_index?: number | null+}++interface InsertRow {+ id: string+ automation_id: string+ parent_step_id: string | null+ branch: 'yes' | 'no' | null+ step_type: string+ step_config: Record<string, unknown>+ position: number+}++const uid = =>+ typeof crypto !== 'undefined' && 'randomUUID' in crypto+ ? crypto.randomUUID()+ : Math.random().toString(36).slice(2) + Date.now().toString(36)++export async function replaceSteps(+ automationId: string,+ input: BuilderStepInput[],+): Promise<string | null> {+ const admin = supabaseAdmin()+ const { error: delErr } = await admin+ .from('automation_steps')+ .delete()+ .eq('automation_id', automationId)+ if (delErr) return delErr.message+ return insertSteps(automationId, input)+}++export async function insertSteps(+ automationId: string,+ input: BuilderStepInput[],+): Promise<string | null> {+ if (!input || input.length === 0) return null++ const looksFlat = input.some(+ (s) => s.branch !== undefined || s.parent_index !== undefined,+ )+ const tree = looksFlat ? seedsToTree(input) : input++ const rows: InsertRow[] = []+ function walk(+ steps: BuilderStepInput[],+ parentId: string | null,+ branch: 'yes' | 'no' | null,+ ) {+ steps.forEach((s, idx) => {+ const id = s.id ?? uid()+ rows.push({+ id,+ automation_id: automationId,+ parent_step_id: parentId,+ branch,+ step_type: s.step_type,+ step_config: s.step_config ?? {},+ position: idx,+ })+ if (s.step_type === 'condition' && s.branches) {+ if (s.branches.yes) walk(s.branches.yes, id, 'yes')+ if (s.branches.no) walk(s.branches.no, id, 'no')+ }+ })+ }+ walk(tree, null, null)++ if (rows.length === 0) return null+ const { error } = await supabaseAdmin().from('automation_steps').insert(rows)+ return error?.message ?? null+}++function seedsToTree(seeds: BuilderStepInput[]): BuilderStepInput[] {+ const nodes: BuilderStepInput[] = seeds.map((s) => ({+ ...s,+ branches: { yes: [], no: [] },+ }))+ const roots: BuilderStepInput[] = []+ nodes.forEach((n, i) => {+ const seed = seeds[i]+ if (seed.parent_index == null) {+ roots.push(n)+ } else {+ const parent = nodes[seed.parent_index]+ parent.branches = parent.branches ?? { yes: [], no: [] }+ const bucket = (seed.branch ?? 'yes') as 'yes' | 'no'+ ;(parent.branches[bucket] ??= []).push(n)+ }+ })+ return roots+}++/**+ * Load the steps for an automation and rebuild the nested tree shape+ * the builder UI expects. One query, O(n) assembly.+ */+export interface BuilderStepNode extends BuilderStepInput {+ id: string+ branches: { yes: BuilderStepNode[]; no: BuilderStepNode[] }+}++interface DbStep {+ id: string+ parent_step_id: string | null+ branch: 'yes' | 'no' | null+ step_type: string+ step_config: Record<string, unknown>+ position: number+}++export async function loadStepsTree(automationId: string): Promise<BuilderStepNode[]> {+ const { data, error } = await supabaseAdmin()+ .from('automation_steps')+ .select('*')+ .eq('automation_id', automationId)+ .order('position', { ascending: true })++ if (error) throw new Error(error.message)+ const rows = (data ?? []) as DbStep[]++ const byId = new Map<string, BuilderStepNode>()+ for (const row of rows) {+ byId.set(row.id, {+ id: row.id,+ step_type: row.step_type,+ step_config: row.step_config ?? {},+ branches: { yes: [], no: [] },+ })+ }++ const roots: BuilderStepNode[] = []+ for (const row of rows) {+ const node = byId.get(row.id)!+ if (row.parent_step_id) {+ const parent = byId.get(row.parent_step_id)+ if (parent) {+ const bucket = (row.branch ?? 'yes') as 'yes' | 'no'+ parent.branches[bucket].push(node)+ }+ } else {+ roots.push(node)+ }+ }+ return roots+}diff --git a/src/lib/automations/admin-client.ts b/src/lib/automations/admin-client.tsnew file mode 100644index 0000000000..173373c973--- /dev/null+++ b/src/lib/automations/admin-client.ts@@ -0,0 +1,16 @@+import { createClient, type SupabaseClient } from '@supabase/supabase-js'++// Lazy, shared service-role client for automation engine work.+// Mirrors the pattern used by the webhook handler+// (src/app/api/whatsapp/webhook/route.ts).+let _adminClient: SupabaseClient | null = null++export function supabaseAdmin(): SupabaseClient {+ if (!_adminClient) {+ _adminClient = createClient(+ process.env.NEXT_PUBLIC_SUPABASE_URL!,+ process.env.SUPABASE_SERVICE_ROLE_KEY!,+ )+ }+ return _adminClient+}diff --git a/src/lib/automations/engine.ts b/src/lib/automations/engine.tsindex c21a25cd5b..535b1f64e5 100644--- a/src/lib/automations/engine.ts+++ b/src/lib/automations/engine.ts@@ -16,6 +16,7 @@ import type { } from '@/types' import { supabaseAdmin } from './admin-client' import { engineSendText, engineSendTemplate } from './meta-send'+import { isDeliverableUrl } from '@/lib/webhooks/ssrf' // ------------------------------------------------------------ // Public API@@ -527,11 +528,23 @@ async function runStep(step: AutomationStep, args: ExecuteArgs): Promise<string> case 'send_webhook': { const cfg = step.step_config as SendWebhookStepConfig if (!cfg.url) throw new Error('send_webhook needs url')+ // SSRF guard: the URL and headers are account-controlled and the+ // server makes the request, so refuse any destination that resolves+ // to a private / loopback / link-local / reserved address. Mirrors+ // the webhook_endpoints delivery path (see lib/webhooks/deliver.ts).+ if (!(await isDeliverableUrl(cfg.url))) {+ throw new Error('send_webhook: destination not allowed')+ } const body = cfg.body_template ? interpolate(cfg.body_template, args) : JSON.stringify(args.context) const res = await fetch(cfg.url, { method: 'POST', headers: { 'content-type': 'application/json', ...(cfg.headers ?? {}) }, body,+ // Do NOT follow redirects — a public URL could 3xx-bounce to an+ // internal address, defeating the guard above. Bound the request+ // so a hung/slow internal host can't tie up the runner.+ redirect: 'manual',+ signal: AbortSignal.timeout(10_000), }) if (!res.ok) throw new Error(`webhook returned ${res.status}`) return `webhook ${res.status}`diff --git a/src/lib/automations/engine.test.ts b/src/lib/automations/engine.test.tsindex e9617fce83..e7b8fe2a99 100644--- a/src/lib/automations/engine.test.ts+++ b/src/lib/automations/engine.test.ts@@ -224,6 +224,44 @@ describe("update_contact_field — custom fields", => { }); }); +describe("send_webhook — SSRF guard (GHSA-8jqh-598v-rfxc)", => {+ it("refuses a private / link-local destination and never calls fetch", async => {+ const fetchSpy = vi.fn(async => ({ ok: true, status: 200 }));+ vi.stubGlobal("fetch", fetchSpy);++ h.state.owned = { id: "c1" };+ h.state.automations = [automationWithUpdateStep()];+ // Aimed at the cloud metadata endpoint — the classic SSRF target.+ h.state.steps = [webhookStep("http://169.254.169.254/latest/meta-data/")];++ await runAutomationsForTrigger({+ accountId: ACCOUNT,+ triggerType: "new_message_received",+ contactId: "c1",+ context: {},+ });++ // The automation matched and its steps were loaded (so we genuinely+ // reached the send_webhook case)...+ expect(h.state.fromCalls).toContain("automation_steps");+ // ...yet the guard blocked it before any outbound request left the box.+ expect(fetchSpy).not.toHaveBeenCalled();++ vi.unstubAllGlobals();+ });+});++function webhookStep(url: string) {+ return {+ id: "s1",+ automation_id: "a1",+ step_type: "send_webhook",+ position: 0,+ parent_step_id: null,+ step_config: { url, headers: { "Metadata-Flavor": "Google" }, body_template: "{}" },+ };+}+ function automationWithUpdateStep() { return { id: "a1",Candidate 42ac267fd8ce862a92f99b4a4387eea97a659185ca8a5f77af3153efb3c910c5 · Fix ac9a7a0e9d2c3ebbebda0b25472525712a6366b7760f77e0b945850dc62ab3cf
Releases
Advisory references