feat(server): add github_hmac and none webhook signing modes

Adds two new webhook trigger signing modes for external provider
compatibility:

- github_hmac: accepts X-Hub-Signature-256 header with
  HMAC-SHA256(secret, rawBody), no timestamp prefix. Compatible with
  GitHub, Sentry, and services following the same standard.
- none: no authentication; the 24-char hex publicId in the URL acts
  as the shared secret. For services that cannot add auth headers.

The replay window UI field is hidden when these modes are selected
since neither uses timestamp-based replay protection.

Closes #1892

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Antonio 2026-03-27 23:15:55 -03:00
parent eefe9f39f1
commit cd19834fab
5 changed files with 109 additions and 15 deletions

View file

@ -61,7 +61,7 @@ import type { RoutineTrigger, RoutineVariable } from "@paperclipai/shared";
const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"];
const catchUpPolicies = ["skip_missed", "enqueue_missed_with_cap"];
const triggerKinds = ["schedule", "webhook"];
const signingModes = ["bearer", "hmac_sha256"];
const signingModes = ["bearer", "hmac_sha256", "github_hmac", "none"];
const routineTabs = ["triggers", "runs", "activity"] as const;
const concurrencyPolicyDescriptions: Record<string, string> = {
coalesce_if_active: "Keep one follow-up run queued while an active run is still working.",
@ -75,7 +75,10 @@ const catchUpPolicyDescriptions: Record<string, string> = {
const signingModeDescriptions: Record<string, string> = {
bearer: "Expect a shared bearer token in the Authorization header.",
hmac_sha256: "Expect an HMAC SHA-256 signature over the request using the shared secret.",
github_hmac: "Accept GitHub-style X-Hub-Signature-256 header (HMAC over raw body, no timestamp).",
none: "No authentication — the webhook URL itself acts as a shared secret.",
};
const SIGNING_MODES_WITHOUT_REPLAY_WINDOW = new Set(["github_hmac", "none"]);
type RoutineTab = (typeof routineTabs)[number];
@ -198,13 +201,15 @@ function TriggerEditor({
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Replay window (seconds)</Label>
<Input
value={draft.replayWindowSec}
onChange={(event) => setDraft((current) => ({ ...current, replayWindowSec: event.target.value }))}
/>
</div>
{!SIGNING_MODES_WITHOUT_REPLAY_WINDOW.has(draft.signingMode) && (
<div className="space-y-1.5">
<Label className="text-xs">Replay window (seconds)</Label>
<Input
value={draft.replayWindowSec}
onChange={(event) => setDraft((current) => ({ ...current, replayWindowSec: event.target.value }))}
/>
</div>
)}
</>
)}
</div>
@ -987,10 +992,12 @@ export function RoutineDetail() {
</Select>
<p className="text-xs text-muted-foreground">{signingModeDescriptions[newTrigger.signingMode]}</p>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Replay window (seconds)</Label>
<Input value={newTrigger.replayWindowSec} onChange={(event) => setNewTrigger((current) => ({ ...current, replayWindowSec: event.target.value }))} />
</div>
{!SIGNING_MODES_WITHOUT_REPLAY_WINDOW.has(newTrigger.signingMode) && (
<div className="space-y-1.5">
<Label className="text-xs">Replay window (seconds)</Label>
<Input value={newTrigger.replayWindowSec} onChange={(event) => setNewTrigger((current) => ({ ...current, replayWindowSec: event.target.value }))} />
</div>
)}
</>
)}
</div>