feat(signals): U11 — external signal ingestion (Sentry/Datadog/PagerDuty/webhook)

SignalSource adapter seam with mandatory HMAC verification, replay window +
nonce dedup, SSRF-safe URL handling, body-size/rate-limit/field caps, and a
groupingKey on every normalized Signal for U13's storm guard. Inbound webhooks
create triage tasks via the existing store (no schema change).
This commit is contained in:
gsxdsm
2026-06-15 19:23:23 -07:00
parent 90cfbfb4fc
commit 951c6ef5cd
11 changed files with 1456 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
---
"@runfusion/fusion": minor
---
Ingest external signals (Sentry / Datadog / PagerDuty / generic webhook) into triage tasks via a common `SignalSource` adapter seam (U11, KTD8).
- New `POST /api/signals/:provider` endpoints, mirroring the GitHub ingestion path. Verified, normalized signals create a task in the `triage` column via the existing task store.
- Generic webhook is the must-work path; Sentry/Datadog/PagerDuty are thin adapters with provider-specific HMAC verification + payload normalization. Each normalized `Signal` carries a `groupingKey` (Sentry `issue.id`, PagerDuty `incident.id`, Datadog monitor key; the generic webhook requires a caller-supplied key or falls back to `source + normalized-title`) for the downstream storm guard.
- Security (mandatory): per-provider HMAC against an env-sourced secret (never source-controlled) with 401 on missing/invalid secret or signature — the generic webhook is never an unauthenticated task-creation endpoint; ±5 min replay window + delivery-id nonce dedup; persistent external-id dedup; ~1 MB body cap; per-source rate limit; field-length + meta-byte caps; SSRF-untrusted handling of payload URLs; `meta` stored as data, never rendered as raw HTML.

View File

@@ -770,6 +770,32 @@ For real-time PR/issue badge updates, configure a GitHub App instead of relying
**Fallback Behavior:** **Fallback Behavior:**
When webhook delivery is unavailable, the 5-minute refresh endpoints (`/api/tasks/:id/pr/status`, `/api/tasks/:id/issue/status`) continue to work as the fallback path. Staleness is computed from persisted `lastCheckedAt` timestamps only (no in-memory poller state). When webhook delivery is unavailable, the 5-minute refresh endpoints (`/api/tasks/:id/pr/status`, `/api/tasks/:id/issue/status`) continue to work as the fallback path. Staleness is computed from persisted `lastCheckedAt` timestamps only (no in-memory poller state).
### External Signal Ingestion (Sentry / Datadog / PagerDuty / generic webhook)
Inbound signals from error trackers and alerting tools are ingested into triage
tasks via `POST /api/signals/:provider`. Every endpoint requires a valid HMAC
signature against a per-provider secret — there is no unauthenticated
task-creation endpoint. Secrets come from the environment and are never
source-controlled:
- `FUSION_SIGNAL_WEBHOOK_SECRET` — generic webhook (`POST /api/signals/webhook`).
Sign the raw body with HMAC-SHA256 in `X-Fusion-Signature` (hex, optional
`sha256=` prefix) and send `X-Fusion-Timestamp` (epoch ms) for the replay
window. Payload: `{ id, title, body?, severity?, link?, groupingKey?, timestamp?, meta? }`.
If `groupingKey` is omitted it falls back to `source + normalized-title`.
- `FUSION_SIGNAL_SENTRY_SECRET` — Sentry (`POST /api/signals/sentry`), verifies
`Sentry-Hook-Signature`; `groupingKey` = Sentry `issue.id`.
- `FUSION_SIGNAL_DATADOG_SECRET` — Datadog (`POST /api/signals/datadog`),
verifies `X-Datadog-Signature`; `groupingKey` = monitor `aggreg_key`/`alert_id`.
- `FUSION_SIGNAL_PAGERDUTY_SECRET` — PagerDuty (`POST /api/signals/pagerduty`),
verifies `X-PagerDuty-Signature` (`v1=<hex>`); `groupingKey` = `incident.id`.
**Security:** mandatory HMAC (401 on missing/invalid secret or signature),
replay window (±5 min) + delivery-id nonce dedup, persistent external-id dedup,
~1 MB body cap (413), per-source rate limit (429), field-length caps on
normalized fields, and SSRF-untrusted handling of payload URLs (stored as data,
never fetched). The `meta` JSON is stored as data and never rendered as raw HTML.
### Multi-Instance Deployments ### Multi-Instance Deployments
When running the dashboard on multiple instances behind a load balancer, badge updates can be shared across instances using Redis pub/sub. This ensures that a PR/issue badge change detected on instance A is delivered to subscribed WebSocket clients on instance B. When running the dashboard on multiple instances behind a load balancer, badge updates can be shared across instances using Redis pub/sub. This ensures that a PR/issue badge change detected on instance A is delivered to subscribed WebSocket clients on instance B.

View File

@@ -0,0 +1,342 @@
// @vitest-environment node
import { createHmac } from "node:crypto";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { Task, TaskStore } from "@fusion/core";
import { DeliveryNonceCache, type SignalSource } from "../signal-source.js";
import {
ingestSignal,
resolveSignalSecret,
signalToTaskInput,
getSignalSource,
} from "../routes/register-signal-routes.js";
import { webhookSource } from "../signal-sources/webhook.js";
import { sentrySource } from "../signal-sources/sentry.js";
import { datadogSource } from "../signal-sources/datadog.js";
import { pagerdutySource } from "../signal-sources/pagerduty.js";
function sign(body: string, secret: string): string {
return createHmac("sha256", secret).update(Buffer.from(body)).digest("hex");
}
/** Minimal fake task store implementing only what the ingestion path uses. */
function makeStore() {
const tasks: Task[] = [];
let counter = 0;
const store = {
async listTasks() {
return tasks;
},
async createTask(input: Parameters<TaskStore["createTask"]>[0]) {
const task = {
id: `FN-${++counter}`,
title: input.title,
description: input.description,
column: input.column,
source: input.source,
} as unknown as Task;
tasks.push(task);
return task;
},
_tasks: tasks,
};
return store as unknown as TaskStore & { _tasks: Task[] };
}
const SECRETS: Record<string, string> = {
FUSION_SIGNAL_WEBHOOK_SECRET: "wh-secret",
FUSION_SIGNAL_SENTRY_SECRET: "sentry-secret",
FUSION_SIGNAL_DATADOG_SECRET: "datadog-secret",
FUSION_SIGNAL_PAGERDUTY_SECRET: "pd-secret",
};
const savedEnv: Record<string, string | undefined> = {};
beforeEach(() => {
for (const [k, v] of Object.entries(SECRETS)) {
savedEnv[k] = process.env[k];
process.env[k] = v;
}
});
afterEach(() => {
for (const k of Object.keys(SECRETS)) {
if (savedEnv[k] === undefined) delete process.env[k];
else process.env[k] = savedEnv[k];
}
});
function ctxFor(source: SignalSource, payload: object, headers: Record<string, string>) {
const rawBody = Buffer.from(JSON.stringify(payload));
const lower: Record<string, string | undefined> = {};
for (const [k, v] of Object.entries(headers)) lower[k.toLowerCase()] = v;
return { rawBody, headers: lower, body: payload };
}
describe("getSignalSource registry", () => {
it("resolves all four providers and rejects unknown", () => {
expect(getSignalSource("webhook")).toBe(webhookSource);
expect(getSignalSource("sentry")).toBe(sentrySource);
expect(getSignalSource("datadog")).toBe(datadogSource);
expect(getSignalSource("pagerduty")).toBe(pagerdutySource);
expect(getSignalSource("bogus")).toBeUndefined();
});
});
describe("ingestSignal — generic webhook (must-work path)", () => {
it("creates one triage task for a valid signed payload", async () => {
const store = makeStore();
const ts = Date.now();
const payload = { id: "evt-1", title: "Disk full", severity: "critical", link: "https://ops.example.com/a" };
const { rawBody, headers, body } = ctxFor(webhookSource, payload, {
"x-fusion-signature": sign(JSON.stringify(payload), SECRETS.FUSION_SIGNAL_WEBHOOK_SECRET),
"x-fusion-timestamp": String(ts),
});
const res = await ingestSignal({
source: webhookSource,
store,
rawBody,
headers,
body,
nonceCache: new DeliveryNonceCache(),
});
expect(res.status).toBe(201);
expect(res.taskId).toBe("FN-1");
expect(store._tasks).toHaveLength(1);
expect(store._tasks[0].column).toBe("triage");
const meta = store._tasks[0].source?.sourceMetadata as Record<string, unknown>;
expect(meta.signalSource).toBe("webhook");
expect(meta.signalDeliveryId).toBe("evt-1");
expect(meta.signalGroupingKey).toBe("webhook:disk full");
});
it("rejects with 401 and creates no task when no secret is configured", async () => {
delete process.env.FUSION_SIGNAL_WEBHOOK_SECRET;
const store = makeStore();
const payload = { id: "x", title: "y" };
const { rawBody, headers, body } = ctxFor(webhookSource, payload, {
"x-fusion-signature": "whatever",
"x-fusion-timestamp": String(Date.now()),
});
const res = await ingestSignal({
source: webhookSource,
store,
rawBody,
headers,
body,
nonceCache: new DeliveryNonceCache(),
});
expect(res.status).toBe(401);
expect(store._tasks).toHaveLength(0);
});
it("rejects with 401 on an invalid signature", async () => {
const store = makeStore();
const payload = { id: "x", title: "y" };
const { rawBody, headers, body } = ctxFor(webhookSource, payload, {
"x-fusion-signature": sign("tampered", SECRETS.FUSION_SIGNAL_WEBHOOK_SECRET),
"x-fusion-timestamp": String(Date.now()),
});
const res = await ingestSignal({
source: webhookSource,
store,
rawBody,
headers,
body,
nonceCache: new DeliveryNonceCache(),
});
expect(res.status).toBe(401);
expect(store._tasks).toHaveLength(0);
});
it("rejects a stale timestamp (replay window)", async () => {
const store = makeStore();
const payload = { id: "x", title: "y" };
const stale = Date.now() - 10 * 60_000;
const { rawBody, headers, body } = ctxFor(webhookSource, payload, {
"x-fusion-signature": sign(JSON.stringify(payload), SECRETS.FUSION_SIGNAL_WEBHOOK_SECRET),
"x-fusion-timestamp": String(stale),
});
const res = await ingestSignal({
source: webhookSource,
store,
rawBody,
headers,
body,
nonceCache: new DeliveryNonceCache(),
});
expect(res.status).toBe(401);
expect(store._tasks).toHaveLength(0);
});
it("rejects a replayed delivery nonce", async () => {
const store = makeStore();
const nonceCache = new DeliveryNonceCache();
const payload = { id: "dup", title: "y" };
const headersInput = {
"x-fusion-signature": sign(JSON.stringify(payload), SECRETS.FUSION_SIGNAL_WEBHOOK_SECRET),
"x-fusion-timestamp": String(Date.now()),
};
const first = ctxFor(webhookSource, payload, headersInput);
const r1 = await ingestSignal({ source: webhookSource, store, ...first, nonceCache });
expect(r1.status).toBe(201);
const second = ctxFor(webhookSource, payload, headersInput);
const r2 = await ingestSignal({ source: webhookSource, store, ...second, nonceCache });
expect(r2.status).toBe(401);
expect(store._tasks).toHaveLength(1);
});
it("dedupes a duplicate external id against existing tasks (no double-create)", async () => {
const store = makeStore();
const payload = { id: "same-id", title: "y" };
const mk = () =>
ctxFor(webhookSource, payload, {
"x-fusion-signature": sign(JSON.stringify(payload), SECRETS.FUSION_SIGNAL_WEBHOOK_SECRET),
"x-fusion-timestamp": String(Date.now()),
});
// Two separate nonce caches simulate a process restart (nonce dedup reset),
// so the persistent external-id dedup is what must catch the duplicate.
const r1 = await ingestSignal({ source: webhookSource, store, ...mk(), nonceCache: new DeliveryNonceCache() });
expect(r1.status).toBe(201);
const r2 = await ingestSignal({ source: webhookSource, store, ...mk(), nonceCache: new DeliveryNonceCache() });
expect(r2.status).toBe(200);
expect(r2.deduped).toBe(true);
expect(r2.taskId).toBe("FN-1");
expect(store._tasks).toHaveLength(1);
});
it("returns 400 with no task on a malformed payload", async () => {
const store = makeStore();
const payload = { nope: true }; // missing id/title
const { rawBody, headers, body } = ctxFor(webhookSource, payload, {
"x-fusion-signature": sign(JSON.stringify(payload), SECRETS.FUSION_SIGNAL_WEBHOOK_SECRET),
"x-fusion-timestamp": String(Date.now()),
});
const res = await ingestSignal({
source: webhookSource,
store,
rawBody,
headers,
body,
nonceCache: new DeliveryNonceCache(),
});
expect(res.status).toBe(400);
expect(store._tasks).toHaveLength(0);
});
});
describe("ingestSignal — Sentry adapter", () => {
it("creates one triage task with normalized title/severity/link + groupingKey from issue.id", async () => {
const store = makeStore();
const payload = {
data: {
issue: {
id: "1234",
title: "TypeError: undefined is not a function",
level: "fatal",
web_url: "https://sentry.io/issues/1234",
shortId: "WEB-12",
project: "web",
},
},
timestamp: Date.now(),
};
const raw = JSON.stringify(payload);
const res = await ingestSignal({
source: sentrySource,
store,
rawBody: Buffer.from(raw),
headers: { "sentry-hook-signature": sign(raw, SECRETS.FUSION_SIGNAL_SENTRY_SECRET) },
body: payload,
nonceCache: new DeliveryNonceCache(),
});
expect(res.status).toBe(201);
const task = store._tasks[0];
const meta = task.source?.sourceMetadata as Record<string, unknown>;
expect(meta.signalGroupingKey).toBe("1234");
expect(meta.signalSeverity).toBe("critical");
expect(task.title).toContain("TypeError");
});
it("rejects an unsigned Sentry webhook with 401", async () => {
const store = makeStore();
const payload = { data: { issue: { id: "1", title: "x" } } };
const res = await ingestSignal({
source: sentrySource,
store,
rawBody: Buffer.from(JSON.stringify(payload)),
headers: {},
body: payload,
nonceCache: new DeliveryNonceCache(),
});
expect(res.status).toBe(401);
expect(store._tasks).toHaveLength(0);
});
});
describe("ingestSignal — Datadog & PagerDuty adapters (groupingKey from native primitive)", () => {
it("Datadog uses aggreg_key as groupingKey", async () => {
const store = makeStore();
const payload = { aggreg_key: "agg-7", event_id: "ev-7", title: "High CPU", alert_type: "error" };
const raw = JSON.stringify(payload);
const res = await ingestSignal({
source: datadogSource,
store,
rawBody: Buffer.from(raw),
headers: { "x-datadog-signature": sign(raw, SECRETS.FUSION_SIGNAL_DATADOG_SECRET) },
body: payload,
nonceCache: new DeliveryNonceCache(),
});
expect(res.status).toBe(201);
const meta = store._tasks[0].source?.sourceMetadata as Record<string, unknown>;
expect(meta.signalGroupingKey).toBe("agg-7");
expect(meta.signalDeliveryId).toBe("ev-7");
});
it("PagerDuty uses incident.id as groupingKey", async () => {
const store = makeStore();
const payload = {
event: {
id: "evt-pd-1",
event_type: "incident.triggered",
occurred_at: new Date().toISOString(),
data: { id: "PINC1", title: "DB down", urgency: "high", html_url: "https://pd.example.com/i/PINC1", status: "triggered" },
},
};
const raw = JSON.stringify(payload);
const res = await ingestSignal({
source: pagerdutySource,
store,
rawBody: Buffer.from(raw),
headers: { "x-pagerduty-signature": `v1=${sign(raw, SECRETS.FUSION_SIGNAL_PAGERDUTY_SECRET)}` },
body: payload,
nonceCache: new DeliveryNonceCache(),
});
expect(res.status).toBe(201);
const meta = store._tasks[0].source?.sourceMetadata as Record<string, unknown>;
expect(meta.signalGroupingKey).toBe("PINC1");
expect(meta.signalDeliveryId).toBe("evt-pd-1");
});
});
describe("helpers", () => {
it("resolveSignalSecret reads the provider env var", () => {
expect(resolveSignalSecret(webhookSource)).toBe("wh-secret");
expect(resolveSignalSecret(webhookSource, {})).toBeUndefined();
});
it("signalToTaskInput maps to a triage task with provenance metadata", () => {
const input = signalToTaskInput({
source: "webhook",
externalId: "e",
groupingKey: "g",
title: "t",
severity: "critical",
});
expect(input.column).toBe("triage");
expect(input.priority).toBe("high");
expect(input.source?.sourceType).toBe("api");
});
});

View File

@@ -0,0 +1,124 @@
// @vitest-environment node
import { createHmac } from "node:crypto";
import { describe, expect, it } from "vitest";
import {
DeliveryNonceCache,
SignalRateLimiter,
applySignalCaps,
fallbackGroupingKey,
isSafeExternalUrl,
isWithinReplayWindow,
normalizeTitleForGrouping,
verifyHmacSignature,
type Signal,
SIGNAL_FIELD_CAPS,
} from "../signal-source.js";
function sign(body: string, secret: string): string {
return createHmac("sha256", secret).update(Buffer.from(body)).digest("hex");
}
describe("verifyHmacSignature", () => {
it("accepts a matching signature and rejects a wrong one", () => {
const body = Buffer.from(JSON.stringify({ a: 1 }));
const secret = "s3cr3t";
const good = createHmac("sha256", secret).update(body).digest("hex");
expect(verifyHmacSignature(body, good, secret)).toBe(true);
expect(verifyHmacSignature(body, good, "wrong")).toBe(false);
expect(verifyHmacSignature(body, undefined, secret)).toBe(false);
expect(verifyHmacSignature(body, "deadbeef", secret)).toBe(false);
});
});
describe("isWithinReplayWindow", () => {
it("accepts recent timestamps and rejects stale or missing ones", () => {
const now = 1_000_000_000_000;
expect(isWithinReplayWindow(now, now)).toBe(true);
expect(isWithinReplayWindow(now - 4 * 60_000, now)).toBe(true);
expect(isWithinReplayWindow(now - 6 * 60_000, now)).toBe(false);
expect(isWithinReplayWindow(undefined, now)).toBe(false);
});
});
describe("DeliveryNonceCache", () => {
it("rejects a replayed nonce within the window", () => {
const cache = new DeliveryNonceCache(1000);
expect(cache.check("a", 0)).toBe(true);
expect(cache.check("a", 500)).toBe(false);
// After TTL the nonce is evictable again.
expect(cache.check("a", 2000)).toBe(true);
});
});
describe("SignalRateLimiter", () => {
it("caps a flood per source", () => {
const limiter = new SignalRateLimiter(1000, 3);
expect(limiter.allow("x", 0)).toBe(true);
expect(limiter.allow("x", 1)).toBe(true);
expect(limiter.allow("x", 2)).toBe(true);
expect(limiter.allow("x", 3)).toBe(false);
// A different source is independent.
expect(limiter.allow("y", 3)).toBe(true);
// After the window slides, capacity returns.
expect(limiter.allow("x", 2000)).toBe(true);
});
});
describe("isSafeExternalUrl (SSRF guard)", () => {
it("rejects loopback, private, and non-http schemes; accepts public https", () => {
expect(isSafeExternalUrl("https://sentry.io/issues/1")).toBe(true);
expect(isSafeExternalUrl("http://example.com")).toBe(true);
expect(isSafeExternalUrl("https://localhost/x")).toBe(false);
expect(isSafeExternalUrl("http://127.0.0.1")).toBe(false);
expect(isSafeExternalUrl("http://10.0.0.5")).toBe(false);
expect(isSafeExternalUrl("http://192.168.1.1")).toBe(false);
expect(isSafeExternalUrl("http://169.254.169.254")).toBe(false);
expect(isSafeExternalUrl("file:///etc/passwd")).toBe(false);
expect(isSafeExternalUrl("javascript:alert(1)")).toBe(false);
expect(isSafeExternalUrl(undefined)).toBe(false);
});
});
describe("grouping key fallback", () => {
it("derives source + normalized title", () => {
expect(normalizeTitleForGrouping(" Some ERROR ")).toBe("some error");
expect(fallbackGroupingKey("webhook", "Disk Full!")).toBe("webhook:disk full!");
});
});
describe("applySignalCaps", () => {
it("truncates long fields and drops oversized meta + unsafe links", () => {
const signal: Signal = {
source: "webhook",
externalId: "e1",
groupingKey: "g1",
title: "x".repeat(SIGNAL_FIELD_CAPS.title + 50),
body: "y".repeat(SIGNAL_FIELD_CAPS.body + 50),
severity: "error",
link: "http://127.0.0.1/internal",
meta: { big: "z".repeat(SIGNAL_FIELD_CAPS.metaBytes + 100) },
};
const capped = applySignalCaps(signal);
expect(capped.title.length).toBe(SIGNAL_FIELD_CAPS.title);
expect(capped.body?.length).toBe(SIGNAL_FIELD_CAPS.body);
expect(capped.link).toBeUndefined(); // unsafe internal URL dropped
expect(capped.meta).toBeUndefined(); // oversized meta dropped
});
it("keeps a safe external link and small meta", () => {
const capped = applySignalCaps({
source: "sentry",
externalId: "e1",
groupingKey: "g1",
title: "boom",
severity: "critical",
link: "https://sentry.io/issues/42",
meta: { project: "web" },
});
expect(capped.link).toBe("https://sentry.io/issues/42");
expect(capped.meta).toEqual({ project: "web" });
});
});
export { sign };

View File

@@ -168,6 +168,7 @@ import { registerProxyRoutes } from "./routes/register-proxy-routes.js";
import { registerModelRoutes } from "./routes/register-model-routes.js"; import { registerModelRoutes } from "./routes/register-model-routes.js";
import { registerCustomProviderRoutes } from "./routes/register-custom-provider-routes.js"; import { registerCustomProviderRoutes } from "./routes/register-custom-provider-routes.js";
import { registerUsageRoutes } from "./routes/register-usage-routes.js"; import { registerUsageRoutes } from "./routes/register-usage-routes.js";
import { registerSignalRoutes } from "./routes/register-signal-routes.js";
import { registerAuthRoutes } from "./routes/register-auth-routes.js"; import { registerAuthRoutes } from "./routes/register-auth-routes.js";
import { registerRuntimeProviderRoutes } from "./routes/register-runtime-provider-routes.js"; import { registerRuntimeProviderRoutes } from "./routes/register-runtime-provider-routes.js";
import { registerFnBinaryRoutes } from "./routes/register-fn-binary-routes.js"; import { registerFnBinaryRoutes } from "./routes/register-fn-binary-routes.js";
@@ -1989,6 +1990,10 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}); });
registerUsageRoutes(routeContext); registerUsageRoutes(routeContext);
// U11 — inbound external signal webhooks (Sentry/Datadog/PagerDuty/generic).
// Each route HMAC-verifies against a per-provider secret; never an
// unauthenticated task-creation endpoint.
registerSignalRoutes(routeContext);
registerUpdateCheckRoutes(routeContext); registerUpdateCheckRoutes(routeContext);
registerDiagnosticsRoutes(routeContext); registerDiagnosticsRoutes(routeContext);
// CLI Agent Executor hook ingestion (U17) — per-session token auth, exempt from // CLI Agent Executor hook ingestion (U17) — per-session token auth, exempt from

View File

@@ -0,0 +1,238 @@
import type { Request, Response } from "express";
import type { Task, TaskStore } from "@fusion/core";
import { ApiError, badRequest, rateLimited, unauthorized } from "../api-error.js";
import {
DeliveryNonceCache,
SIGNAL_MAX_BODY_BYTES,
SignalRateLimiter,
type Signal,
type SignalProvider,
type SignalSource,
} from "../signal-source.js";
import { webhookSource } from "../signal-sources/webhook.js";
import { sentrySource } from "../signal-sources/sentry.js";
import { datadogSource } from "../signal-sources/datadog.js";
import { pagerdutySource } from "../signal-sources/pagerduty.js";
import type { ApiRouteRegistrar } from "./types.js";
/**
* U11 — inbound external-signal webhook routes.
*
* Mounts `POST /api/signals/:provider` for each supported provider. Each request
* is HMAC-verified by the provider adapter against a per-provider secret sourced
* from the environment (never source-controlled). Verified, normalized signals
* create a task in the `triage` column via the scoped task store, mirroring the
* GitHub ingestion path.
*
* Security applied here (mandatory, not deferred):
* - mandatory HMAC verify → 401 on missing/invalid secret or signature
* - body-size cap (~1 MB) → 413
* - per-source rate limit → 429
* - delivery-id nonce dedup (replay) → 401
* - persistent external-id dedup against existing tasks → 200, no new task
* - field-length caps + meta-byte cap applied in the adapter (applySignalCaps)
* - URLs are SSRF-untrusted (stored as data only; unsafe links dropped)
* - `meta` stored as JSON data, never rendered as raw HTML
*/
/** Thin registry — kept minimal per scope discipline (no heavy abstraction). */
const SIGNAL_SOURCES: Record<SignalProvider, SignalSource> = {
webhook: webhookSource,
sentry: sentrySource,
datadog: datadogSource,
pagerduty: pagerdutySource,
};
export function getSignalSource(provider: string): SignalSource | undefined {
return SIGNAL_SOURCES[provider as SignalProvider];
}
/**
* Resolve a provider's HMAC secret. Env var is the canonical, never
* source-controlled source. An optional resolver (e.g. encrypted settings) can
* be supplied for deployments that store secrets there.
*/
export function resolveSignalSecret(
source: SignalSource,
env: NodeJS.ProcessEnv = process.env,
): string | undefined {
const value = env[source.secretEnvVar];
return value && value.length > 0 ? value : undefined;
}
const SIGNAL_DELIVERY_META_KEY = "signalDeliveryId";
const SIGNAL_GROUPING_META_KEY = "signalGroupingKey";
const SIGNAL_SOURCE_META_KEY = "signalSource";
/**
* Persistent delivery dedup: has a task already been created for this provider +
* external id? Scans recent tasks for the provenance marker. Mirrors the spirit
* of `github-tracking-dedup.ts` for the inbound path.
*/
async function findExistingSignalTask(
store: TaskStore,
provider: SignalProvider,
externalId: string,
): Promise<Task | undefined> {
const tasks = await store.listTasks({ slim: true, includeArchived: true });
return tasks.find((t) => {
const meta = t.source?.sourceMetadata as Record<string, unknown> | undefined;
return (
meta?.[SIGNAL_SOURCE_META_KEY] === provider &&
meta?.[SIGNAL_DELIVERY_META_KEY] === externalId
);
});
}
/** Build a task-create input from a normalized signal. */
export function signalToTaskInput(signal: Signal): Parameters<TaskStore["createTask"]>[0] {
const lines: string[] = [];
if (signal.body) lines.push(signal.body);
if (signal.link) lines.push(`\nSource: ${signal.link}`);
lines.push(`\nSeverity: ${signal.severity}`);
const description = `${signal.title}\n\n${lines.join("\n")}`.trim();
return {
title: signal.title,
description,
column: "triage",
priority: signal.severity === "critical" ? "high" : undefined,
source: {
// Reuse the existing `api` source type — signals arrive over the API
// webhook surface. Provenance is carried in sourceMetadata so we do not
// need a core schema/type change for U11.
sourceType: "api",
sourceMetadata: {
[SIGNAL_SOURCE_META_KEY]: signal.source,
[SIGNAL_DELIVERY_META_KEY]: signal.externalId,
[SIGNAL_GROUPING_META_KEY]: signal.groupingKey,
signalSeverity: signal.severity,
signalLink: signal.link,
// `meta` is stored as data only and never rendered as raw HTML.
signalMeta: signal.meta,
},
},
};
}
/**
* Pure ingestion core: verify → dedup → normalize → create task. Exposed for
* unit testing without the full express app.
*/
export interface SignalIngestDeps {
source: SignalSource;
store: TaskStore;
rawBody: Buffer;
headers: Record<string, string | undefined>;
body: unknown;
nonceCache: DeliveryNonceCache;
}
export interface SignalIngestResult {
status: number;
taskId?: string;
deduped?: boolean;
error?: string;
}
export async function ingestSignal(deps: SignalIngestDeps): Promise<SignalIngestResult> {
const { source, store, rawBody, headers, body, nonceCache } = deps;
const secret = resolveSignalSecret(source);
// 1. Mandatory HMAC verification. Missing/invalid secret or signature → 401.
const verification = source.verify({ rawBody, headers, secret });
if (!verification.valid) {
return { status: verification.status ?? 401, error: verification.error ?? "Unauthorized" };
}
// 2. Normalize (malformed payload → throw → caller maps to 4xx, no task).
let signal: Signal | null;
try {
signal = source.normalize(body, { rawBody, headers, secret });
} catch (err) {
return { status: 400, error: err instanceof Error ? err.message : "Malformed payload" };
}
if (!signal) {
// Valid-but-not-actionable (e.g. ping/health) → accepted, no task.
return { status: 200 };
}
// 3. Replay nonce dedup (same delivery id within the replay window) → 401.
if (!nonceCache.check(`${signal.source}:${signal.externalId}`)) {
return { status: 401, error: "Replayed delivery rejected" };
}
// 4. Persistent external-id dedup → 200 with the existing task, no new task.
const existing = await findExistingSignalTask(store, signal.source, signal.externalId);
if (existing) {
return { status: 200, taskId: existing.id, deduped: true };
}
// 5. Create the triage task.
const task = await store.createTask(signalToTaskInput(signal));
return { status: 201, taskId: task.id };
}
export const registerSignalRoutes: ApiRouteRegistrar = (ctx) => {
const { router, getScopedStore } = ctx;
// Shared per-process state for replay dedup + rate limiting.
const nonceCache = new DeliveryNonceCache();
const rateLimiter = new SignalRateLimiter();
router.post("/signals/:provider", async (req: Request, res: Response) => {
const provider = Array.isArray(req.params.provider)
? req.params.provider[0]
: req.params.provider;
const source = getSignalSource(provider);
if (!source) {
throw badRequest(`Unknown signal provider: ${String(provider)}`);
}
// Body-size cap (~1 MB) → 413.
const rawBody = (req as Request & { rawBody?: Buffer }).rawBody;
if (rawBody && rawBody.byteLength > SIGNAL_MAX_BODY_BYTES) {
throw new ApiError(413, "Signal payload too large");
}
// Per-source rate limit → 429.
if (!rateLimiter.allow(source.provider)) {
throw rateLimited(`Rate limit exceeded for signal source: ${source.provider}`);
}
if (!rawBody) {
// Without the raw body we cannot HMAC-verify — never create a task.
throw unauthorized("Raw body not available for signature verification");
}
const headers: Record<string, string | undefined> = {};
for (const [key, value] of Object.entries(req.headers)) {
headers[key.toLowerCase()] = Array.isArray(value) ? value[0] : value;
}
const store = await getScopedStore(req);
const result = await ingestSignal({
source,
store,
rawBody,
headers,
body: req.body,
nonceCache,
});
if (result.status === 401) {
throw unauthorized(result.error ?? "Unauthorized");
}
if (result.status === 400) {
throw badRequest(result.error ?? "Malformed payload");
}
res.status(result.status).json({
ok: result.status < 400,
taskId: result.taskId,
deduped: result.deduped ?? false,
});
});
};

View File

@@ -0,0 +1,300 @@
import { createHmac, timingSafeEqual } from "node:crypto";
/**
* U11 — External signal ingestion seam.
*
* This module defines the common `SignalSource` adapter interface plus the
* shared security primitives (HMAC verification, replay window, nonce dedup,
* body-size cap, field-length caps, SSRF-untrusted URL handling) that every
* provider adapter reuses. It mirrors the GitHub ingestion path
* (`github-webhooks.ts`) which also lives in `packages/dashboard/src`.
*
* Scope discipline (per the plan): the generic webhook adapter is the
* must-work path. Sentry/Datadog/PagerDuty are thin adapters that supply
* provider-specific HMAC verification + payload normalization. We deliberately
* keep the registry thin — adapters are looked up by a small map, no heavy
* abstraction until more providers exist.
*/
/** Normalized severity for an ingested signal. */
export type SignalSeverity = "critical" | "error" | "warning" | "info";
/** Supported external signal providers. */
export type SignalProvider = "sentry" | "datadog" | "pagerduty" | "webhook";
/**
* Field-length caps applied to every normalized {@link Signal} before it is
* turned into a task. External input is never trusted — caps bound storage and
* prevent abuse via oversized fields.
*/
export const SIGNAL_FIELD_CAPS = {
title: 300,
body: 8_000,
/** Cap on the serialized `meta` JSON (bytes). */
metaBytes: 4_096,
groupingKey: 256,
link: 2_048,
} as const;
/** Maximum accepted request body size for any signal webhook (bytes, ~1 MB). */
export const SIGNAL_MAX_BODY_BYTES = 1_048_576;
/** Replay window: reject signed payloads whose timestamp is outside ±5 min. */
export const SIGNAL_REPLAY_WINDOW_MS = 5 * 60 * 1_000;
/**
* A normalized external signal. Provider adapters map their native payloads
* onto this shape. Downstream (U13 storm guard) groups re-firing signals by
* {@link Signal.groupingKey}.
*/
export interface Signal {
/** Source provider that produced this signal. */
source: SignalProvider;
/** Stable provider-specific external id (used for delivery dedup). */
externalId: string;
/**
* Grouping primitive used by the storm guard to collapse re-firing signals
* (Sentry issue.id, PagerDuty incident.id, Datadog monitor key). The generic
* webhook requires the caller to supply one; otherwise it falls back to
* `source + normalized-title` (see {@link fallbackGroupingKey}).
*/
groupingKey: string;
/** Short human-visible title. */
title: string;
/** Optional longer description / detail. */
body?: string;
/** Normalized severity. */
severity: SignalSeverity;
/**
* Optional canonical URL back to the source. Treated as SSRF-untrusted: it is
* stored as data and only rendered as an external link, never fetched server
* side. See {@link isSafeExternalUrl}.
*/
link?: string;
/** Provider event timestamp (epoch ms), used for the replay window. */
timestamp?: number;
/**
* Non-rendered descriptor data carried from the source. Stored as JSON data
* only — never rendered as raw HTML in the dashboard. Capped to
* {@link SIGNAL_FIELD_CAPS.metaBytes}.
*/
meta?: Record<string, unknown>;
}
/** Context passed to an adapter's {@link SignalSource.verify}. */
export interface SignalVerifyContext {
/** Raw request body bytes (required for HMAC). */
rawBody: Buffer;
/** Lower-cased request headers. */
headers: Record<string, string | undefined>;
/** Per-provider secret resolved from env / encrypted settings. */
secret: string | undefined;
}
/** Result of an adapter's signature verification. */
export interface SignalVerifyResult {
valid: boolean;
/** HTTP status to return on failure (always 401 for auth failures). */
status?: number;
error?: string;
}
/**
* A provider adapter. Kept intentionally thin: a mandatory `verify(ctx)` (HMAC)
* plus a `normalize(payload)` that yields a {@link Signal} (or `null` for a
* payload that is valid but not actionable, e.g. a ping/health event).
*/
export interface SignalSource {
readonly provider: SignalProvider;
/**
* The env var name carrying this provider's HMAC secret. Secrets are NEVER
* source-controlled; they come from the environment (or encrypted settings).
*/
readonly secretEnvVar: string;
/** Mandatory HMAC signature verification against a per-provider secret. */
verify(ctx: SignalVerifyContext): SignalVerifyResult;
/**
* Normalize a parsed payload into a {@link Signal}. Throws (or returns null)
* for malformed/non-actionable payloads — callers translate a throw into a
* 4xx with no task created.
*/
normalize(payload: unknown, ctx: SignalVerifyContext): Signal | null;
}
// ── Shared security helpers ────────────────────────────────────────────────
/**
* Constant-time comparison of a computed HMAC against a provided signature.
* `signatureHex` may carry a `sha256=` / `v1=` style prefix-stripped value.
*/
export function verifyHmacSignature(
rawBody: Buffer,
signatureHex: string | undefined,
secret: string,
): boolean {
if (!signatureHex) return false;
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
if (signatureHex.length !== expected.length) return false;
try {
return timingSafeEqual(Buffer.from(signatureHex), Buffer.from(expected));
} catch {
return false;
}
}
/** True when the provider event timestamp is inside the replay window. */
export function isWithinReplayWindow(
timestampMs: number | undefined,
nowMs: number = Date.now(),
windowMs: number = SIGNAL_REPLAY_WINDOW_MS,
): boolean {
if (timestampMs === undefined || !Number.isFinite(timestampMs)) {
// No timestamp → cannot bound replay; reject to stay safe.
return false;
}
return Math.abs(nowMs - timestampMs) <= windowMs;
}
/**
* In-memory delivery-id nonce store with TTL eviction. Used to reject replayed
* deliveries (same external/delivery id) within the replay window. Mirrors the
* spirit of `github-tracking-dedup.ts` for the inbound path.
*/
export class DeliveryNonceCache {
private readonly seen = new Map<string, number>();
constructor(private readonly ttlMs: number = SIGNAL_REPLAY_WINDOW_MS) {}
/** Returns true if this is a fresh delivery; false if a replay. */
check(nonce: string, nowMs: number = Date.now()): boolean {
this.evict(nowMs);
if (this.seen.has(nonce)) return false;
this.seen.set(nonce, nowMs);
return true;
}
private evict(nowMs: number): void {
for (const [key, ts] of this.seen) {
if (nowMs - ts > this.ttlMs) this.seen.delete(key);
}
}
/** Test/diagnostic helper. */
size(): number {
return this.seen.size;
}
}
/**
* Per-source sliding-window rate limiter (in-memory). Caps a flood of inbound
* signals from a single provider.
*/
export class SignalRateLimiter {
private readonly hits = new Map<string, number[]>();
constructor(
private readonly windowMs: number = 60_000,
private readonly max: number = 120,
) {}
/** Returns true if the request is allowed; false if over the cap. */
allow(key: string, nowMs: number = Date.now()): boolean {
const cutoff = nowMs - this.windowMs;
const arr = (this.hits.get(key) ?? []).filter((t) => t > cutoff);
if (arr.length >= this.max) {
this.hits.set(key, arr);
return false;
}
arr.push(nowMs);
this.hits.set(key, arr);
return true;
}
}
/**
* SSRF guard for URLs found in payloads. We never fetch these URLs; this only
* gates whether a link is safe to store/surface as an external link. Rejects
* non-http(s) schemes and obvious internal/loopback/private hosts.
*/
export function isSafeExternalUrl(url: string | undefined): boolean {
if (!url) return false;
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return false;
}
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return false;
const host = parsed.hostname.toLowerCase();
if (
host === "localhost" ||
host === "0.0.0.0" ||
host === "::1" ||
host.endsWith(".localhost") ||
host.endsWith(".internal") ||
host.endsWith(".local")
) {
return false;
}
// IPv4 private / loopback / link-local ranges.
const ipv4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (ipv4) {
const [a, b] = [Number(ipv4[1]), Number(ipv4[2])];
if (a === 10) return false;
if (a === 127) return false;
if (a === 169 && b === 254) return false;
if (a === 172 && b >= 16 && b <= 31) return false;
if (a === 192 && b === 168) return false;
}
return true;
}
/** Truncate a string to a cap, trimming whitespace. */
function capString(value: string, cap: number): string {
const trimmed = value.trim();
return trimmed.length > cap ? trimmed.slice(0, cap) : trimmed;
}
/** Normalize a title for the fallback grouping key (lower-case, collapsed). */
export function normalizeTitleForGrouping(title: string): string {
return title.trim().toLowerCase().replace(/\s+/g, " ");
}
/**
* Generic-webhook grouping-key fallback: `source + normalized-title` when the
* caller does not supply an explicit grouping key.
*/
export function fallbackGroupingKey(source: SignalProvider, title: string): string {
return `${source}:${normalizeTitleForGrouping(title)}`;
}
/**
* Apply field-length caps and meta-byte cap to a normalized signal. Drops a
* `link` that is not an SSRF-safe external URL (kept as data only otherwise via
* caller choice — here we drop unsafe links entirely). Returns a new object.
*/
export function applySignalCaps(signal: Signal): Signal {
let meta = signal.meta;
if (meta) {
let serialized = "";
try {
serialized = JSON.stringify(meta);
} catch {
serialized = "";
}
if (!serialized || Buffer.byteLength(serialized, "utf8") > SIGNAL_FIELD_CAPS.metaBytes) {
// Oversized or unserializable meta is dropped rather than truncated mid-JSON.
meta = undefined;
}
}
const link =
signal.link && isSafeExternalUrl(signal.link)
? capString(signal.link, SIGNAL_FIELD_CAPS.link)
: undefined;
return {
...signal,
title: capString(signal.title, SIGNAL_FIELD_CAPS.title) || "(untitled signal)",
body: signal.body ? capString(signal.body, SIGNAL_FIELD_CAPS.body) : undefined,
groupingKey: capString(signal.groupingKey, SIGNAL_FIELD_CAPS.groupingKey),
link,
meta,
};
}

View File

@@ -0,0 +1,103 @@
import {
applySignalCaps,
isWithinReplayWindow,
verifyHmacSignature,
type Signal,
type SignalSeverity,
type SignalSource,
type SignalVerifyContext,
type SignalVerifyResult,
} from "../signal-source.js";
/**
* Datadog adapter (scaffold).
*
* Datadog webhooks don't ship a built-in HMAC header, so the convention is to
* include a shared-secret HMAC the user templates into a custom header
* (`X-Datadog-Signature` = HMAC-SHA256(hex) of the raw body). `groupingKey` is
* the Datadog monitor/aggregation key (`alert_id` / `aggreg_key`).
*/
function mapAlertType(value: unknown): SignalSeverity {
switch (value) {
case "error":
return "critical";
case "warning":
case "warn":
return "warning";
case "success":
case "recovery":
case "info":
return "info";
default:
return "error";
}
}
export const datadogSource: SignalSource = {
provider: "datadog",
secretEnvVar: "FUSION_SIGNAL_DATADOG_SECRET",
verify(ctx: SignalVerifyContext): SignalVerifyResult {
if (!ctx.secret) {
return { valid: false, status: 401, error: "Datadog signing secret is not configured" };
}
const signature = ctx.headers["x-datadog-signature"];
if (!signature) {
return { valid: false, status: 401, error: "Missing X-Datadog-Signature header" };
}
if (!verifyHmacSignature(ctx.rawBody, signature, ctx.secret)) {
return { valid: false, status: 401, error: "Invalid signature" };
}
const tsHeader = ctx.headers["x-datadog-timestamp"];
if (tsHeader && !isWithinReplayWindow(Number(tsHeader))) {
return { valid: false, status: 401, error: "Timestamp outside replay window" };
}
return { valid: true };
},
normalize(payload: unknown): Signal | null {
if (!payload || typeof payload !== "object") {
throw new Error("Payload must be a JSON object");
}
const p = payload as Record<string, unknown>;
const groupingKey =
(typeof p.aggreg_key === "string" && p.aggreg_key) ||
(typeof p.alert_id === "string" && p.alert_id) ||
(typeof p.id === "string" && p.id) ||
"";
if (!groupingKey) throw new Error("Missing Datadog aggreg_key/alert_id");
const externalId =
(typeof p.event_id === "string" && p.event_id) ||
(typeof p.id === "string" && p.id) ||
groupingKey;
const title =
(typeof p.title === "string" && p.title) ||
(typeof p.event_title === "string" && p.event_title) ||
`Datadog alert ${groupingKey}`;
const signal: Signal = {
source: "datadog",
externalId,
groupingKey,
title,
body: typeof p.body === "string" ? p.body : typeof p.text_only_msg === "string" ? p.text_only_msg : undefined,
severity: mapAlertType(p.alert_type),
link: typeof p.link === "string" ? p.link : typeof p.url === "string" ? p.url : undefined,
timestamp:
typeof p.date === "number"
? p.date
: typeof p.last_updated === "number"
? p.last_updated
: undefined,
meta: {
priority: typeof p.priority === "string" ? p.priority : undefined,
scope: typeof p.scope === "string" ? p.scope : undefined,
},
};
return applySignalCaps(signal);
},
};

View File

@@ -0,0 +1,95 @@
import {
applySignalCaps,
isWithinReplayWindow,
verifyHmacSignature,
type Signal,
type SignalSeverity,
type SignalSource,
type SignalVerifyContext,
type SignalVerifyResult,
} from "../signal-source.js";
/**
* PagerDuty adapter (scaffold).
*
* PagerDuty v3 webhooks sign with `X-PagerDuty-Signature: v1=<hex>` =
* HMAC-SHA256 of the raw body using the subscription secret. `groupingKey` is
* the PagerDuty `incident.id` (native dedup primitive for U13's storm guard).
*/
function mapUrgency(urgency: unknown, severity: unknown): SignalSeverity {
if (severity === "critical") return "critical";
if (severity === "error") return "error";
if (severity === "warning") return "warning";
if (severity === "info") return "info";
return urgency === "high" ? "critical" : "warning";
}
function parsePagerDutySignatureHeader(header: string | undefined): string | undefined {
if (!header) return undefined;
// Header may carry multiple comma-separated `v1=<hex>` signatures (key rotation).
for (const part of header.split(",")) {
const trimmed = part.trim();
if (trimmed.startsWith("v1=")) return trimmed.slice("v1=".length);
}
return undefined;
}
export const pagerdutySource: SignalSource = {
provider: "pagerduty",
secretEnvVar: "FUSION_SIGNAL_PAGERDUTY_SECRET",
verify(ctx: SignalVerifyContext): SignalVerifyResult {
if (!ctx.secret) {
return { valid: false, status: 401, error: "PagerDuty signing secret is not configured" };
}
const signature = parsePagerDutySignatureHeader(ctx.headers["x-pagerduty-signature"]);
if (!signature) {
return { valid: false, status: 401, error: "Missing X-PagerDuty-Signature header" };
}
if (!verifyHmacSignature(ctx.rawBody, signature, ctx.secret)) {
return { valid: false, status: 401, error: "Invalid signature" };
}
return { valid: true };
},
normalize(payload: unknown): Signal | null {
if (!payload || typeof payload !== "object") {
throw new Error("Payload must be a JSON object");
}
const p = payload as Record<string, unknown>;
const event = (p.event as Record<string, unknown> | undefined) ?? p;
const data = (event.data as Record<string, unknown> | undefined) ?? event;
const incidentId =
(typeof data.id === "string" && data.id) ||
(typeof p.id === "string" && p.id) ||
"";
if (!incidentId) throw new Error("Missing PagerDuty incident.id");
const title =
(typeof data.title === "string" && data.title) ||
(typeof data.summary === "string" && data.summary) ||
`PagerDuty incident ${incidentId}`;
const eventId =
typeof event.id === "string" ? event.id : incidentId;
const signal: Signal = {
source: "pagerduty",
externalId: eventId,
groupingKey: incidentId,
title,
body: typeof data.description === "string" ? data.description : undefined,
severity: mapUrgency(data.urgency, data.severity),
link: typeof data.html_url === "string" ? data.html_url : undefined,
timestamp:
typeof event.occurred_at === "string" ? Date.parse(event.occurred_at) : undefined,
meta: {
eventType: typeof event.event_type === "string" ? event.event_type : undefined,
status: typeof data.status === "string" ? data.status : undefined,
},
};
return applySignalCaps(signal);
},
};

View File

@@ -0,0 +1,117 @@
import {
applySignalCaps,
isWithinReplayWindow,
verifyHmacSignature,
type Signal,
type SignalSeverity,
type SignalSource,
type SignalVerifyContext,
type SignalVerifyResult,
} from "../signal-source.js";
/**
* Sentry adapter (scaffold).
*
* Sentry signs webhooks with `Sentry-Hook-Signature` = HMAC-SHA256(hex) of the
* raw request body using the integration's client secret. `groupingKey` is the
* Sentry `issue.id` (its native dedup primitive) — used by U13's storm guard.
*/
function mapLevel(level: unknown): SignalSeverity {
switch (level) {
case "fatal":
case "critical":
return "critical";
case "error":
return "error";
case "warning":
return "warning";
case "info":
case "debug":
return "info";
default:
return "error";
}
}
export const sentrySource: SignalSource = {
provider: "sentry",
secretEnvVar: "FUSION_SIGNAL_SENTRY_SECRET",
verify(ctx: SignalVerifyContext): SignalVerifyResult {
if (!ctx.secret) {
return { valid: false, status: 401, error: "Sentry signing secret is not configured" };
}
const signature = ctx.headers["sentry-hook-signature"];
if (!signature) {
return { valid: false, status: 401, error: "Missing Sentry-Hook-Signature header" };
}
if (!verifyHmacSignature(ctx.rawBody, signature, ctx.secret)) {
return { valid: false, status: 401, error: "Invalid signature" };
}
// Sentry sends `Sentry-Hook-Timestamp` (epoch ms) on installation events;
// when absent on issue events we fall back to the payload timestamp checked
// during normalize. Reject only when an explicit header is stale.
const tsHeader = ctx.headers["sentry-hook-timestamp"];
if (tsHeader && !isWithinReplayWindow(Number(tsHeader))) {
return { valid: false, status: 401, error: "Timestamp outside replay window" };
}
return { valid: true };
},
normalize(payload: unknown): Signal | null {
if (!payload || typeof payload !== "object") {
throw new Error("Payload must be a JSON object");
}
const p = payload as Record<string, unknown>;
const data = (p.data as Record<string, unknown> | undefined) ?? p;
const issue =
(data.issue as Record<string, unknown> | undefined) ??
(data.error as Record<string, unknown> | undefined) ??
(data.event as Record<string, unknown> | undefined);
if (!issue || typeof issue !== "object") {
throw new Error("Missing Sentry issue/event data");
}
const issueId =
typeof issue.id === "string"
? issue.id
: typeof issue.id === "number"
? String(issue.id)
: "";
if (!issueId) throw new Error("Missing Sentry issue.id");
const title =
(typeof issue.title === "string" && issue.title) ||
(typeof issue.culprit === "string" && issue.culprit) ||
`Sentry issue ${issueId}`;
const link =
typeof issue.web_url === "string"
? issue.web_url
: typeof issue.permalink === "string"
? issue.permalink
: undefined;
const signal: Signal = {
source: "sentry",
externalId: issueId,
groupingKey: issueId,
title,
body: typeof issue.culprit === "string" ? issue.culprit : undefined,
severity: mapLevel(issue.level),
link,
timestamp:
typeof p.timestamp === "number"
? p.timestamp
: typeof issue.lastSeen === "string"
? Date.parse(issue.lastSeen)
: undefined,
meta: {
project: typeof issue.project === "string" ? issue.project : undefined,
shortId: typeof issue.shortId === "string" ? issue.shortId : undefined,
},
};
return applySignalCaps(signal);
},
};

View File

@@ -0,0 +1,97 @@
import {
applySignalCaps,
fallbackGroupingKey,
isWithinReplayWindow,
verifyHmacSignature,
type Signal,
type SignalSource,
type SignalVerifyContext,
type SignalVerifyResult,
type SignalSeverity,
} from "../signal-source.js";
/**
* Generic webhook adapter — the must-work path (per the plan's scope
* discipline). It is NEVER an unauthenticated task-creation endpoint: a missing
* or invalid secret/signature is rejected with 401.
*
* Signature: HMAC-SHA256 of the raw body, hex-encoded, in the
* `X-Fusion-Signature` header (optionally `sha256=`-prefixed).
* Timestamp: `X-Fusion-Timestamp` (epoch ms) drives the replay window.
*
* Payload contract (JSON):
* {
* "id": "<stable external id>", // required
* "title": "<short title>", // required
* "body"?: "...",
* "severity"?: "critical|error|warning|info",
* "link"?: "https://...",
* "groupingKey"?: "<caller-supplied>", // else falls back to source+title
* "timestamp"?: <epoch ms>,
* "meta"?: { ... }
* }
*/
const SEVERITIES: SignalSeverity[] = ["critical", "error", "warning", "info"];
function coerceSeverity(value: unknown): SignalSeverity {
return typeof value === "string" && (SEVERITIES as string[]).includes(value)
? (value as SignalSeverity)
: "warning";
}
function stripSig(header: string | undefined): string | undefined {
if (!header) return undefined;
return header.startsWith("sha256=") ? header.slice("sha256=".length) : header;
}
export const webhookSource: SignalSource = {
provider: "webhook",
secretEnvVar: "FUSION_SIGNAL_WEBHOOK_SECRET",
verify(ctx: SignalVerifyContext): SignalVerifyResult {
if (!ctx.secret) {
return { valid: false, status: 401, error: "Webhook signing secret is not configured" };
}
const signature = stripSig(ctx.headers["x-fusion-signature"]);
if (!signature) {
return { valid: false, status: 401, error: "Missing signature header" };
}
if (!verifyHmacSignature(ctx.rawBody, signature, ctx.secret)) {
return { valid: false, status: 401, error: "Invalid signature" };
}
const tsHeader = ctx.headers["x-fusion-timestamp"];
const ts = tsHeader ? Number(tsHeader) : undefined;
if (!isWithinReplayWindow(ts)) {
return { valid: false, status: 401, error: "Timestamp outside replay window" };
}
return { valid: true };
},
normalize(payload: unknown): Signal | null {
if (!payload || typeof payload !== "object") {
throw new Error("Payload must be a JSON object");
}
const p = payload as Record<string, unknown>;
const externalId = typeof p.id === "string" ? p.id.trim() : "";
const title = typeof p.title === "string" ? p.title.trim() : "";
if (!externalId) throw new Error("Missing required field: id");
if (!title) throw new Error("Missing required field: title");
const supplied = typeof p.groupingKey === "string" ? p.groupingKey.trim() : "";
const groupingKey = supplied || fallbackGroupingKey("webhook", title);
const signal: Signal = {
source: "webhook",
externalId,
groupingKey,
title,
body: typeof p.body === "string" ? p.body : undefined,
severity: coerceSeverity(p.severity),
link: typeof p.link === "string" ? p.link : undefined,
timestamp: typeof p.timestamp === "number" ? p.timestamp : undefined,
meta: p.meta && typeof p.meta === "object" ? (p.meta as Record<string, unknown>) : undefined,
};
return applySignalCaps(signal);
},
};