57dc91585d
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
310 lines
9.7 KiB
JavaScript
310 lines
9.7 KiB
JavaScript
/**
|
|
* @file Universal webhook delivery for fired alerts. A "target" is an outbound
|
|
* destination described by the provider registry (server/lib/webhook-providers.js)
|
|
* — Slack, Discord, Teams, Mattermost, Rocket.Chat, Telegram, PagerDuty,
|
|
* Opsgenie, Splunk On-Call, Zapier, Make, n8n, Pipedream, or a generic endpoint.
|
|
* When the alerting engine fires an alert (server/lib/alerts.js), it calls
|
|
* dispatchAlert(), which formats the provider-native payload and POSTs it to
|
|
* every enabled target (optionally scoped to specific rules) with a timeout and
|
|
* bounded retry/backoff. Every attempt-chain is recorded in webhook_deliveries.
|
|
*
|
|
* Delivery is detached and fully fail-safe: it never throws into, slows, or
|
|
* blocks the alert path or hook ingestion.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const crypto = require("crypto");
|
|
const { stmts } = require("../db");
|
|
const {
|
|
PROVIDERS,
|
|
WEBHOOK_TYPES,
|
|
isGenericFamily,
|
|
resolveUrl,
|
|
resolveAuthHeaders,
|
|
formatPayload,
|
|
truncate,
|
|
} = require("./webhook-providers");
|
|
|
|
// Tunables (env-overridable so tests can shrink timeouts/backoff). All read at
|
|
// module load — restart to change.
|
|
function posEnv(name, fallback) {
|
|
const raw = parseInt(process.env[name], 10);
|
|
return Number.isFinite(raw) && raw > 0 ? raw : fallback;
|
|
}
|
|
const TIMEOUT_MS = posEnv("WEBHOOK_TIMEOUT_MS", 10_000);
|
|
const MAX_ATTEMPTS = posEnv("WEBHOOK_MAX_ATTEMPTS", 3);
|
|
const RETRY_BASE_MS = posEnv("WEBHOOK_RETRY_BASE_MS", 1500);
|
|
|
|
// Enabled-target cache. Alert fires are hot; targets only change through the
|
|
// CRUD routes, which call invalidateWebhookCache().
|
|
let targetsCache = null;
|
|
|
|
function invalidateWebhookCache() {
|
|
targetsCache = null;
|
|
}
|
|
|
|
/** Parse the JSON columns and coerce the enabled flag for a raw target row. */
|
|
function normalizeTarget(row) {
|
|
if (!row) return null;
|
|
let headers = null;
|
|
let ruleIds = null;
|
|
let config = null;
|
|
try {
|
|
headers = row.headers ? JSON.parse(row.headers) : null;
|
|
} catch {
|
|
/* tolerate hand-edited bad JSON — extra headers simply not applied */
|
|
}
|
|
try {
|
|
ruleIds = row.rule_ids ? JSON.parse(row.rule_ids) : null;
|
|
} catch {
|
|
/* tolerate bad JSON — target falls back to "all rules" */
|
|
}
|
|
try {
|
|
config = row.config ? JSON.parse(row.config) : null;
|
|
} catch {
|
|
/* tolerate bad JSON — provider config falls back to empty */
|
|
}
|
|
return { ...row, enabled: row.enabled === 1, headers, rule_ids: ruleIds, config };
|
|
}
|
|
|
|
function loadEnabledTargets() {
|
|
if (targetsCache) return targetsCache;
|
|
targetsCache = stmts.listEnabledWebhookTargets.all().map(normalizeTarget);
|
|
return targetsCache;
|
|
}
|
|
|
|
/**
|
|
* Build the HTTP request for a target + alert: resolved URL, provider-native
|
|
* serialized body, and headers (provider auth headers, plus custom headers and
|
|
* an optional HMAC-SHA256 signature for the generic family). Exported for tests.
|
|
*/
|
|
function buildRequest(target, alert) {
|
|
const url = resolveUrl(target);
|
|
if (!url) throw new Error(`no URL resolved for webhook type "${target.type}"`);
|
|
|
|
const payload = formatPayload(target.type, alert, target.config || {});
|
|
const body = JSON.stringify(payload);
|
|
|
|
const headers = {
|
|
"Content-Type": "application/json",
|
|
"User-Agent": "claude-code-agent-monitor/webhooks",
|
|
...resolveAuthHeaders(target),
|
|
};
|
|
|
|
if (isGenericFamily(target.type)) {
|
|
if (target.headers && typeof target.headers === "object") {
|
|
for (const [k, v] of Object.entries(target.headers)) {
|
|
if (typeof k !== "string" || typeof v !== "string") continue;
|
|
// Never let a custom header clobber Content-Type or the signature.
|
|
const lower = k.toLowerCase();
|
|
if (lower === "content-type" || lower === "x-webhook-signature") continue;
|
|
headers[k] = v;
|
|
}
|
|
}
|
|
if (target.secret) {
|
|
const ts = new Date().toISOString();
|
|
const sig = crypto.createHmac("sha256", target.secret).update(`${ts}.${body}`).digest("hex");
|
|
headers["X-Webhook-Timestamp"] = ts;
|
|
headers["X-Webhook-Signature"] = `sha256=${sig}`;
|
|
}
|
|
}
|
|
|
|
return { url, body, headers };
|
|
}
|
|
|
|
// ── Delivery ────────────────────────────────────────────────────────────────
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => {
|
|
const t = setTimeout(resolve, ms);
|
|
if (t.unref) t.unref();
|
|
});
|
|
}
|
|
|
|
async function postOnce(url, body, headers) {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
if (timer.unref) timer.unref();
|
|
try {
|
|
const res = await fetch(url, {
|
|
method: "POST",
|
|
headers,
|
|
body,
|
|
signal: controller.signal,
|
|
redirect: "follow",
|
|
});
|
|
// Read the response body — some providers (Splunk On-Call) signal failure
|
|
// in the body despite a 200, so deliver() may need to inspect it. Also
|
|
// frees the socket promptly. (Named distinctly from the `body` param.)
|
|
let responseBody = "";
|
|
try {
|
|
responseBody = await res.text();
|
|
} catch {
|
|
/* body read is best-effort */
|
|
}
|
|
return {
|
|
ok: res.status >= 200 && res.status < 300,
|
|
status: res.status,
|
|
error: null,
|
|
body: responseBody,
|
|
};
|
|
} catch (err) {
|
|
const timedOut = err?.name === "AbortError";
|
|
return {
|
|
ok: false,
|
|
status: null,
|
|
error: timedOut ? "timeout" : err?.message || "network error",
|
|
body: "",
|
|
};
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
function recordDelivery(target, alertId, { status, statusCode, attempts, error }) {
|
|
try {
|
|
stmts.insertWebhookDelivery.run(
|
|
target.id,
|
|
target.name,
|
|
target.type,
|
|
alertId == null ? null : alertId,
|
|
status,
|
|
statusCode == null ? null : statusCode,
|
|
attempts,
|
|
error == null ? null : truncate(error, 500)
|
|
);
|
|
stmts.pruneWebhookDeliveries.run();
|
|
} catch (err) {
|
|
console.warn("[WEBHOOK] delivery log write failed:", err?.message || err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Deliver one alert to one target with bounded retry. Retries on transport
|
|
* errors, HTTP 429, and 5xx; gives up immediately on other 4xx (misconfigured
|
|
* URL / bad payload won't fix themselves). Always records the outcome and
|
|
* never throws. Returns `{ ok, status, attempts, error }`.
|
|
*/
|
|
async function deliver(target, alert) {
|
|
let built;
|
|
try {
|
|
built = buildRequest(target, alert);
|
|
} catch (err) {
|
|
recordDelivery(target, alert.id, {
|
|
status: "failed",
|
|
statusCode: null,
|
|
attempts: 0,
|
|
error: `request build failed: ${err?.message || err}`,
|
|
});
|
|
return { ok: false, status: null, attempts: 0, error: "request build failed" };
|
|
}
|
|
|
|
let attempts = 0;
|
|
let status = null;
|
|
let error = null;
|
|
const verifyResponse = PROVIDERS[target.type]?.verifyResponse;
|
|
|
|
while (attempts < MAX_ATTEMPTS) {
|
|
attempts += 1;
|
|
const res = await postOnce(built.url, built.body, built.headers);
|
|
status = res.status;
|
|
error = res.error;
|
|
if (res.ok) {
|
|
// Some providers (Splunk On-Call) return 200 even on rejection — let the
|
|
// provider veto a "successful" status by inspecting the response body.
|
|
const verdict = verifyResponse ? verifyResponse(res.body) : { ok: true };
|
|
if (verdict.ok) {
|
|
recordDelivery(target, alert.id, {
|
|
status: "success",
|
|
statusCode: status,
|
|
attempts,
|
|
error: null,
|
|
});
|
|
return { ok: true, status, attempts };
|
|
}
|
|
// A logical rejection won't fix on retry — fail immediately.
|
|
error = verdict.error || "provider reported failure";
|
|
break;
|
|
}
|
|
const retryable = status == null || status === 429 || status >= 500;
|
|
if (!retryable || attempts >= MAX_ATTEMPTS) break;
|
|
await sleep(RETRY_BASE_MS * attempts);
|
|
}
|
|
|
|
recordDelivery(target, alert.id, {
|
|
status: "failed",
|
|
statusCode: status,
|
|
attempts,
|
|
error: error || (status ? `HTTP ${status}` : "request failed"),
|
|
});
|
|
return {
|
|
ok: false,
|
|
status,
|
|
attempts,
|
|
error: error || (status ? `HTTP ${status}` : "request failed"),
|
|
};
|
|
}
|
|
|
|
/** A target receives an alert when it has no rule scope, or the alert's rule is in scope. */
|
|
function targetAppliesTo(target, alert) {
|
|
if (!Array.isArray(target.rule_ids) || target.rule_ids.length === 0) return true;
|
|
return target.rule_ids.includes(alert.rule_id);
|
|
}
|
|
|
|
/**
|
|
* Fan an alert out to every enabled, in-scope target. Returns a promise that
|
|
* settles when all deliveries finish (used by tests); callers in the alert
|
|
* path invoke it fire-and-forget. Never rejects.
|
|
*/
|
|
function dispatchAlert(alert) {
|
|
let targets;
|
|
try {
|
|
targets = loadEnabledTargets();
|
|
} catch (err) {
|
|
console.warn("[WEBHOOK] target load failed:", err?.message || err);
|
|
return Promise.resolve([]);
|
|
}
|
|
const applicable = targets.filter((t) => {
|
|
try {
|
|
return targetAppliesTo(t, alert);
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
if (applicable.length === 0) return Promise.resolve([]);
|
|
return Promise.allSettled(applicable.map((t) => deliver(t, alert)));
|
|
}
|
|
|
|
/**
|
|
* Send a synthetic test alert to a single (already DB-loaded, un-redacted)
|
|
* target. Awaits the result so the route can report success/failure inline.
|
|
*/
|
|
function sendTest(target) {
|
|
const alert = {
|
|
id: null,
|
|
rule_id: null,
|
|
rule_name: "Webhook test",
|
|
rule_type: "test",
|
|
session_id: null,
|
|
agent_id: null,
|
|
message: `Test notification from Claude Code Agent Monitor to "${target.name}". If you can read this, delivery works.`,
|
|
details: { test: true, target: target.name, type: target.type },
|
|
triggered_at: new Date().toISOString(),
|
|
};
|
|
return deliver(target, alert);
|
|
}
|
|
|
|
module.exports = {
|
|
PROVIDERS,
|
|
WEBHOOK_TYPES,
|
|
invalidateWebhookCache,
|
|
loadEnabledTargets,
|
|
normalizeTarget,
|
|
formatPayload,
|
|
buildRequest,
|
|
deliver,
|
|
dispatchAlert,
|
|
sendTest,
|
|
targetAppliesTo,
|
|
};
|