diff --git a/doc/design/COMPONENT-INVENTORY.md b/doc/design/COMPONENT-INVENTORY.md index 050e08fa2e..84bdccd4ab 100644 --- a/doc/design/COMPONENT-INVENTORY.md +++ b/doc/design/COMPONENT-INVENTORY.md @@ -428,3 +428,5 @@ Independently addressable examples live under `Connections/In-task connections` ## Shared setup wizard (2026-09-19) `ui/src/components/SetupWizard.tsx` extracts the Slack setup navigation into reusable numbered steps, a portal sidebar, an optional section-menu takeover outlet, and a single-row footer. `SetupWizardSidebarProvider` owns the portal target and takeover lifecycle. Chat exports retain their existing names and defaults for compatibility. The Design Guide demonstrates the components. The production routine trigger wizard and its Storybook previews share the sidebar takeover, navigation, and footer. `routine-triggers/TriggerWizard.tsx`, `TriggerCard.tsx`, and `WebhookFields.tsx` provide the shared trigger setup, compact editable cards, copyable credentials, and agent instructions. + +`routine-triggers/WebhookUrlWarning.tsx` uses `InlineBanner` for non-blocking localhost, private-network, Tailscale, and HTTP guidance. Setup and saved webhook editors share it; the Design Guide shows each warning. URL classification is heuristic, not a public reachability test. diff --git a/docs/api/routines.md b/docs/api/routines.md index b430d74a56..6ac4a2d5b4 100644 --- a/docs/api/routines.md +++ b/docs/api/routines.md @@ -298,3 +298,5 @@ Finish setup with `PATCH /api/routine-triggers/{id}` and `{ "setupPending": fals For compatibility, API-created triggers without `setupPending: true` are immediately live. Completed triggers cannot be returned to setup mode. Checking a previously enabled webhook observes real deliveries and can start the routine; the management UI explains this difference. Trigger cards support removal with Undo. `PATCH` with `{ "archived": true }` excludes a trigger from routine detail and scheduling, and rejects its webhook deliveries. Setting `archived` back to `false` restores the same URL and credentials. `DELETE` remains the permanent deletion API. + +The webhook wizard and saved trigger editor warn about localhost, private-network addresses, Tailscale hostnames, and HTTP URLs without blocking setup. HTTPS does not imply public access: Tailscale Serve is private, while Funnel can expose the same hostname publicly. These warnings inspect the URL only; they do not resolve DNS or test internet reachability. Use [the HTTPS setup guide](https://docs.paperclip.ing/reference/deploy/https/) to configure public access when the sender is outside your network. diff --git a/ui/src/components/routine-sections/editable-sections.test.tsx b/ui/src/components/routine-sections/editable-sections.test.tsx index b599399b30..d1fc38be14 100644 --- a/ui/src/components/routine-sections/editable-sections.test.tsx +++ b/ui/src/components/routine-sections/editable-sections.test.tsx @@ -95,6 +95,22 @@ describe("TriggersSection", () => { expect(container.textContent).not.toContain("one-time-secret"); }); + it("warns about private URLs without blocking webhook setup or completion", async () => { + routine.triggers = [{ id: "trigger-1", kind: "webhook", enabled: true, setupPending: true, signingMode: "bearer", webhookUrl: "https://paperclip.internal/webhook" }] as RoutineTrigger[]; + await render(); + await click("Resume setup"); + expect(container.textContent).toContain("This webhook URL appears to be private"); + expect(container.querySelector('a[href="https://docs.paperclip.ing/reference/deploy/https/"]')).not.toBeNull(); + expect(button("Check connection").disabled).toBe(false); + await click("Check connection"); + expect(container.textContent).toContain("This webhook URL appears to be private"); + expect(button("Finish without checking").disabled).toBe(false); + await click("Finish without checking"); + expect(api.updateTrigger).toHaveBeenCalledWith("trigger-1", { setupPending: false }); + await click("Edit webhook"); + expect(container.textContent).toContain("This webhook URL appears to be private"); + }); + it("shows polled connection results even when routine context is stale", async () => { routine.triggers = [{ id: "trigger-1", kind: "webhook", enabled: true, setupPending: true, signingMode: "bearer", webhookUrl: "https://paperclip.example/webhook" }] as RoutineTrigger[]; await render(undefined, routine); diff --git a/ui/src/components/routine-triggers/RoutineTriggers.tsx b/ui/src/components/routine-triggers/RoutineTriggers.tsx index a0ae876f36..d1c5f25468 100644 --- a/ui/src/components/routine-triggers/RoutineTriggers.tsx +++ b/ui/src/components/routine-triggers/RoutineTriggers.tsx @@ -26,6 +26,7 @@ import { type TriggerDraft, } from "./TriggerWizard"; import { AgentInstructions, CopyField } from "./WebhookFields"; +import { WebhookUrlWarning } from "./WebhookUrlWarning"; function readDraft(key: string): TriggerDraft | null { try { @@ -521,6 +522,7 @@ function WebhookSettings({ checkBaseline !== null && delivery && delivery.receivedAt !== checkBaseline; return (
+ {secret && (github || trigger.signingMode === "bearer") && ( {title}

{subtitle}

+ {!schedule && draft.step > 0 && } {draft.step === 0 && (
Trigger type diff --git a/ui/src/components/routine-triggers/WebhookUrlWarning.tsx b/ui/src/components/routine-triggers/WebhookUrlWarning.tsx new file mode 100644 index 0000000000..4f326df163 --- /dev/null +++ b/ui/src/components/routine-triggers/WebhookUrlWarning.tsx @@ -0,0 +1,38 @@ +import { InlineBanner } from "@/components/InlineBanner"; +import { webhookUrlWarningReason } from "@/lib/webhook-url-warning"; + +const warnings = { + loopback: { + title: "Other apps can’t reach this localhost URL", + message: "This address points back to the machine sending the request. Services such as GitHub can’t use it to reach Paperclip on your computer.", + }, + private: { + title: "This webhook URL appears to be private", + message: "Only senders with access to this network can reach this address. Apps on the public internet, such as GitHub, usually can’t deliver webhooks here.", + }, + tailscale: { + title: "This Tailscale URL may not be public", + message: "Tailscale Serve is private to your tailnet, even with HTTPS. Apps outside your tailnet need Tailscale Funnel or another public HTTPS address. If Funnel is already enabled for this URL, you can continue.", + }, + https: { + title: "Use HTTPS for webhooks from other apps", + message: "This URL uses HTTP. Many apps require HTTPS, and HTTP does not encrypt webhook credentials or payloads.", + }, + invalid: { + title: "Check the webhook URL", + message: "This is not a valid HTTP or HTTPS URL. Your sending app needs a complete address it can reach.", + }, +}; + +export function WebhookUrlWarning({ url }: { url: string }) { + const reason = webhookUrlWarningReason(url); + if (!reason) return null; + const warning = warnings[reason]; + return +
+

{warning.message}

+

You can continue for local or private-network use. For public senders, use a publicly reachable HTTPS URL.

+ Learn how to set up HTTPS and public access +
+
; +} diff --git a/ui/src/lib/webhook-url-warning.test.ts b/ui/src/lib/webhook-url-warning.test.ts new file mode 100644 index 0000000000..f555ef9bb6 --- /dev/null +++ b/ui/src/lib/webhook-url-warning.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { webhookUrlWarningReason } from "./webhook-url-warning"; + +describe("webhookUrlWarningReason", () => { + it.each([ + ["http://localhost:3100/fire", "loopback"], + ["https://app.localhost./fire", "loopback"], + ["http://127.1/fire", "loopback"], + ["http://0x7f000001/fire", "loopback"], + ["https://[::1]/fire", "loopback"], + ["https://[::ffff:127.0.0.1]/fire", "loopback"], + ["http://0.0.0.0/fire", "loopback"], + ["https://10.1.2.3/fire", "private"], + ["https://172.16.0.1/fire", "private"], + ["https://172.31.255.255/fire", "private"], + ["https://192.168.1.2/fire", "private"], + ["https://169.254.1.2/fire", "private"], + ["https://100.64.0.1/fire", "private"], + ["https://100.127.255.255/fire", "private"], + ["https://[fd00::1]/fire", "private"], + ["https://[fc00::1]/fire", "private"], + ["https://[fe80::1]/fire", "private"], + ["https://[::ffff:192.168.1.2]/fire", "private"], + ["https://paperclip/fire", "private"], + ["https://paperclip.internal/fire", "private"], + ["https://paperclip.local/fire", "private"], + ["https://paperclip.home.arpa/fire", "private"], + ["https://paperclip.example-tailnet.ts.net/fire", "tailscale"], + ["http://PAPERCLIP.EXAMPLE-TAILNET.TS.NET./fire", "tailscale"], + ["http://paperclip.example.com/fire", "https"], + ["https://paperclip.example.com/fire", null], + ["https://172.32.0.1/fire", null], + ["https://100.128.0.1/fire", null], + ["https://[2606:4700::1111]/fire", null], + ["https://[::ffff:8.8.8.8]/fire", null], + ["https://ts.net.example.com/fire", null], + ["ftp://paperclip.example.com/fire", "invalid"], + ["not a URL", "invalid"], + ["", null], + ])("classifies %s as %s", (url, expected) => { + expect(webhookUrlWarningReason(url)).toBe(expected); + }); +}); diff --git a/ui/src/lib/webhook-url-warning.ts b/ui/src/lib/webhook-url-warning.ts new file mode 100644 index 0000000000..d88a57494c --- /dev/null +++ b/ui/src/lib/webhook-url-warning.ts @@ -0,0 +1,37 @@ +export type WebhookUrlWarningReason = "loopback" | "private" | "tailscale" | "https" | "invalid"; + +function ipv4Warning(parts: number[]): WebhookUrlWarningReason | null { + const [a, b] = parts; + if (a === 127 || a === 0) return "loopback"; + if (a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) + || (a === 169 && b === 254) || (a === 100 && b >= 64 && b <= 127)) return "private"; + return null; +} + +/** URL hints only: DNS, firewall rules, and Tailscale Funnel cannot be inferred here. */ +export function webhookUrlWarningReason(value: string): WebhookUrlWarningReason | null { + if (!value) return null; + let url: URL; + try { url = new URL(value); } catch { return "invalid"; } + if (url.protocol !== "http:" && url.protocol !== "https:") return "invalid"; + const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, ""); + if (host === "localhost" || host.endsWith(".localhost") || host === "::1" || host === "::") return "loopback"; + if (host.includes(":")) { + // URL normalizes IPv4-mapped IPv6 addresses to two hexadecimal words. + const mapped = /^::ffff:([\da-f]{1,4}):([\da-f]{1,4})$/.exec(host); + if (mapped) { + const high = parseInt(mapped[1], 16), low = parseInt(mapped[2], 16); + const warning = ipv4Warning([high >> 8, high & 255, low >> 8, low & 255]); + if (warning) return warning; + } + if (/^(f[cd][\da-f]{2}|fe[89ab][\da-f]|fec[\da-f]):/.test(host)) return "private"; + } else if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) { + const warning = ipv4Warning(host.split(".").map(Number)); + if (warning) return warning; + } else { + if (host.endsWith(".ts.net")) return "tailscale"; + if (!host.includes(".") || /\.(local|internal|lan|home|corp|intranet|private|test)$/.test(host) + || host.endsWith(".home.arpa")) return "private"; + } + return url.protocol === "https:" ? null : "https"; +} diff --git a/ui/src/pages/DesignGuide.tsx b/ui/src/pages/DesignGuide.tsx index 21a96e05bb..02f7690039 100644 --- a/ui/src/pages/DesignGuide.tsx +++ b/ui/src/pages/DesignGuide.tsx @@ -1,3 +1,4 @@ +import { WebhookUrlWarning } from "@/components/routine-triggers/WebhookUrlWarning"; import { SetupWizardNavigation, SetupWizardFooter } from "../components/SetupWizard"; import { AgentChatPicker } from "@/components/AgentChatPicker"; import { TaskChatProjectCreatedCard } from "@/components/task-chat/TaskChatProjectCreatedCard"; @@ -2348,6 +2349,12 @@ export function DesignGuide() { {/* ============================================================ */} {/* INLINE BANNER + BUILT-IN AGENTS */} {/* ============================================================ */} +
+
+ {["http://localhost:3100", "https://paperclip.internal", "https://paperclip.example-tailnet.ts.net", "http://paperclip.example.com", "not-a-url"].map((url) => )} +
+
+

Token-backed full-width notice (brandBanner tones). Use{" "} diff --git a/ui/storybook/fixtures/routineWebhooks.tsx b/ui/storybook/fixtures/routineWebhooks.tsx index f2a8d9bec5..4e6a5a5f9d 100644 --- a/ui/storybook/fixtures/routineWebhooks.tsx +++ b/ui/storybook/fixtures/routineWebhooks.tsx @@ -14,7 +14,7 @@ import { storybookAgents, storybookIssues } from "./paperclipData"; const now = new Date("2026-09-18T15:00:00Z"); const routineId = "routine-webhook-story"; const companyId = "company-storybook"; -const webhookUrl = "https://acme.paperclip.example/api/routine-triggers/public/0123456789abcdef01234567/fire"; +const defaultWebhookUrl = "https://acme.paperclip.example/api/routine-triggers/public/0123456789abcdef01234567/fire"; const demoSecret = "storybook-demo-secret-not-a-real-credential"; const actorFields = { createdByAgentId: null, createdByUserId: null, @@ -22,7 +22,7 @@ const actorFields = { createdAt: now, updatedAt: now, }; -function webhook(signingMode: string, index = 1): RoutineTrigger { +function webhook(signingMode: string, index = 1, webhookUrl = defaultWebhookUrl): RoutineTrigger { return { ...actorFields, id: `webhook-${index}`, companyId, routineId, kind: "webhook", label: "Deployment completed", enabled: true, @@ -94,12 +94,13 @@ const routineActivity: ActivityEvent[] = [ type Props = { preview?: ReactNode; + webhookUrl?: string; signingMode: "bearer" | "hmac_sha256" | "github_hmac" | "none"; state: "setup" | "credentials" | "configured" | "failure" | "overview" | "list" | "runs" | "activity"; }; /** The actual application shell and route pages, backed by an in-memory API. */ -export function WebhookReview({ signingMode = "bearer", state = "configured", preview }: Props) { +export function WebhookReview({ signingMode = "bearer", state = "configured", preview, webhookUrl = defaultWebhookUrl }: Props) { const navigate = useNavigate(); const { setSelectedCompanyId } = useCompany(); const [ready, setReady] = useState(false); @@ -112,7 +113,7 @@ export function WebhookReview({ signingMode = "bearer", state = "configured", pr lastTriggeredAt: fresh ? null : now, recentRuns: fresh ? [] : [completedRun], triggers: fresh ? [] : [{ - ...webhook(signingMode), + ...webhook(signingMode, 1, webhookUrl), lastWebhookDelivery: { status: state === "failure" ? "rejected" : "received", receivedAt: now.toISOString(), test: false }, lastResult: state === "failure" ? "Failed to create task: no default agent assigned" @@ -149,7 +150,7 @@ export function WebhookReview({ signingMode = "bearer", state = "configured", pr routinesApi.update = async (_id, patch) => (routine = { ...routine, ...patch }); routinesApi.createTrigger = async (_id, input) => { const trigger: RoutineTrigger = { - ...webhook(String(input.signingMode ?? "bearer"), routine.triggers.length + 1), ...input, + ...webhook(String(input.signingMode ?? "bearer"), routine.triggers.length + 1, webhookUrl), ...input, lastFiredAt: null, lastResult: null, }; routine = { ...routine, triggers: [...routine.triggers, trigger] }; @@ -189,7 +190,7 @@ export function WebhookReview({ signingMode = "bearer", state = "configured", pr issuesApi.listCompact = originalCompactList; window.fetch = previousFetch; }; - }, [signingMode, state]); + }, [signingMode, state, webhookUrl]); if (!ready) return null; return ( diff --git a/ui/storybook/stories/routine-webhooks.stories.tsx b/ui/storybook/stories/routine-webhooks.stories.tsx index de09b1deab..c830222662 100644 --- a/ui/storybook/stories/routine-webhooks.stories.tsx +++ b/ui/storybook/stories/routine-webhooks.stories.tsx @@ -55,3 +55,16 @@ export const Activity: Story = { await expect(canvas.getByRole("navigation", { name: "Routine navigation" })).toBeVisible(); }, }; + +const warningSetup: NonNullable = async (context) => { + await Credentials.play!(context); + const canvas = within(context.canvasElement); + await expect(canvas.getByRole("link", { name: "Learn how to set up HTTPS and public access" })).toBeVisible(); + await expect(canvas.getByRole("button", { name: "Check connection" })).toBeEnabled(); +}; +const deliveryPath = "/api/routine-triggers/public/0123456789abcdef01234567/fire"; +export const LocalhostWarning: Story = { name: "11 · Localhost warning", args: { state: "credentials", webhookUrl: `http://localhost:3100${deliveryPath}` }, play: warningSetup }; +export const TailscaleWarning: Story = { name: "12 · Tailscale public access warning", args: { state: "credentials", webhookUrl: `https://paperclip.example-tailnet.ts.net${deliveryPath}` }, play: warningSetup }; +export const PrivateNetworkWarning: Story = { name: "13 · Internal domain warning", args: { state: "credentials", webhookUrl: `https://paperclip.internal${deliveryPath}` }, play: warningSetup }; +export const HttpWarning: Story = { name: "14 · HTTP warning", args: { state: "credentials", webhookUrl: `http://paperclip.example.com${deliveryPath}` }, play: warningSetup }; +export const ExistingWebhookWarning: Story = { name: "15 · Existing private webhook warning", args: { webhookUrl: `https://192.168.1.10${deliveryPath}` }, play: openSavedWebhook };