Files
PaperClipAI/ui/src/lib/webhook-url-warning.ts
T
DottaandPaperclip da257c3069 Warn when routine webhook URLs may not be publicly reachable (#13684)
## Thinking Path

> - Paperclip helps people manage AI agents and their work.
> - Routines can start that work when another app sends a webhook.
> - Local and private URLs often cannot receive events from public
services.
> - HTTPS alone does not make a Tailscale address public.
> - This pull request explains these limits during setup and editing.
> - Users can still finish setup for senders on their own network.

## Linked Issues or Issue Description

Refs #13637. Webhook setup needs a clear warning when the generated URL
appears local, private, or unencrypted. The warning must explain how to
make the endpoint reachable without blocking private-network use.

## What Changed

- Add a shared warning banner to the Connect, Check connection, and Edit
webhook views.
- Distinguish localhost, private network addresses and domains, HTTP,
and Tailscale hostnames.
- Explain the difference between Tailscale Serve and Funnel. Link to the
Paperclip HTTPS guide.
- Add five full-page Storybook examples, design guide examples, and
documentation.
- Add URL classification tests and a regression test that finishes setup
despite the warning.

## Verification

- Passed 41 focused URL and trigger-flow tests.
- Passed workspace typecheck, workspace build, token gates, and
Storybook build.
- Browser-tested the Tailscale story through Check connection, Finish
setup, and Edit webhook. The warning stays visible and does not block
setup.
- Open Product / Routines / Webhooks stories 11–15 to review the warning
states.
- All 54 PR checks passed, including the full test matrix and eight
browser shards; two optional Storybook jobs were skipped by workflow
policy.
- The server supervisor readiness test timed out once in CI, then passed
on rerun and locally (6 tests).
- The duplicate local full-suite run was stopped after the complete CI
matrix passed.
- Greptile: 5/5 on the current commit, with no unresolved review
comments.

## Risks

- URL checks are hints. They do not test DNS, firewall rules, or actual
reachability.
- A Tailscale hostname can serve either private Serve traffic or public
Funnel traffic. The warning explains this uncertainty and permits both.
- No API, schema, authentication, or webhook delivery behavior changes.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, repository tools, shell
execution, and browser testing. The exact deployment model ID and
context window size are not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-19 12:24:06 -05:00

38 lines
1.8 KiB
TypeScript

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";
}