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.
388 lines
13 KiB
JavaScript
388 lines
13 KiB
JavaScript
/**
|
|
* @file Express router for universal webhook targets across 14 providers
|
|
* (Slack, Discord, Teams, Google Chat, Mattermost, Rocket.Chat, Telegram,
|
|
* PagerDuty, Opsgenie, Splunk On-Call, Zapier, Make, n8n, Pipedream, generic).
|
|
* Provides target CRUD, a synchronous "send test" probe, a per-target delivery
|
|
* log, and redacted provider metadata for the UI. Secrets are never returned —
|
|
* URLs are masked and secret config / header values are redacted in every
|
|
* response. Delivery + provider definitions live in server/lib/.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { Router } = require("express");
|
|
const { v4: uuidv4 } = require("uuid");
|
|
const { stmts } = require("../db");
|
|
const { invalidateWebhookCache, normalizeTarget, sendTest } = require("../lib/webhooks");
|
|
const {
|
|
PROVIDERS,
|
|
WEBHOOK_TYPES,
|
|
isGenericFamily,
|
|
resolveUrl,
|
|
urlRequired,
|
|
publicProviders,
|
|
} = require("../lib/webhook-providers");
|
|
|
|
const router = Router();
|
|
|
|
// ── Serialization (redacted) ──────────────────────────────────────────────
|
|
|
|
// Reveal the host + last 4 chars so a user can recognize which webhook this is
|
|
// without exposing any embedded secret token.
|
|
function maskUrl(url) {
|
|
if (!url) return "…";
|
|
try {
|
|
const u = new URL(url);
|
|
const tail = url.length > 4 ? url.slice(-4) : "";
|
|
return `${u.protocol}//${u.host}/…${tail}`;
|
|
} catch {
|
|
return "…";
|
|
}
|
|
}
|
|
|
|
// Custom header values can carry auth tokens — return only the keys, masked.
|
|
function redactHeaders(headers) {
|
|
if (!headers || typeof headers !== "object") return null;
|
|
const keys = Object.keys(headers);
|
|
if (keys.length === 0) return null;
|
|
const out = {};
|
|
for (const k of keys) out[k] = "••••";
|
|
return out;
|
|
}
|
|
|
|
// Mask provider config fields flagged secret (routing keys, api keys, tokens);
|
|
// show the rest (chat_id, region, severity, …).
|
|
function redactConfig(type, config) {
|
|
if (!config || typeof config !== "object") return null;
|
|
const fields = PROVIDERS[type]?.fields || [];
|
|
const secretKeys = new Set(fields.filter((f) => f.secret).map((f) => f.key));
|
|
const out = {};
|
|
for (const [k, v] of Object.entries(config)) out[k] = secretKeys.has(k) ? "••••" : v;
|
|
return Object.keys(out).length ? out : null;
|
|
}
|
|
|
|
function serializeTarget(row) {
|
|
const t = normalizeTarget(row);
|
|
let last = null;
|
|
try {
|
|
last = stmts.lastWebhookDeliveryForTarget.get(t.id) || null;
|
|
} catch {
|
|
/* delivery log read is best-effort */
|
|
}
|
|
return {
|
|
id: t.id,
|
|
name: t.name,
|
|
type: t.type,
|
|
enabled: t.enabled,
|
|
url_preview: maskUrl(resolveUrl(t)),
|
|
has_secret: !!t.secret,
|
|
headers: isGenericFamily(t.type) ? redactHeaders(t.headers) : null,
|
|
config: redactConfig(t.type, t.config),
|
|
rule_ids: t.rule_ids && t.rule_ids.length ? t.rule_ids : null,
|
|
created_at: t.created_at,
|
|
updated_at: t.updated_at,
|
|
last_delivery: last
|
|
? {
|
|
status: last.status,
|
|
status_code: last.status_code,
|
|
attempts: last.attempts,
|
|
error: last.error,
|
|
created_at: last.created_at,
|
|
}
|
|
: null,
|
|
};
|
|
}
|
|
|
|
// ── Validation ────────────────────────────────────────────────────────────
|
|
|
|
function bad(res, message) {
|
|
return res.status(400).json({ error: { code: "INVALID_INPUT", message } });
|
|
}
|
|
|
|
function validateUrl(url, type) {
|
|
if (typeof url !== "string" || !url.trim()) return { ok: false, error: "url is required" };
|
|
let u;
|
|
try {
|
|
u = new URL(url.trim());
|
|
} catch {
|
|
return { ok: false, error: "url must be a valid URL" };
|
|
}
|
|
if (u.protocol !== "https:" && u.protocol !== "http:") {
|
|
return { ok: false, error: "url must use http or https" };
|
|
}
|
|
// Most providers' endpoints are https-only; only the generic family with
|
|
// https:false (generic, n8n) permits http (for local/self-hosted testing).
|
|
const allowHttp = PROVIDERS[type]?.https === false;
|
|
if (!allowHttp && u.protocol !== "https:") {
|
|
return { ok: false, error: `${type} webhook URL must use https` };
|
|
}
|
|
return { ok: true, url: url.trim() };
|
|
}
|
|
|
|
// Validate (and normalize) provider config, merging supplied values over a base
|
|
// (the existing config on PATCH) so a single field can change without re-sending
|
|
// secrets. Returns { ok, value } where value is the full config object or null.
|
|
function validateConfig(type, input, base = {}) {
|
|
const fields = PROVIDERS[type]?.fields || [];
|
|
if (input != null && (typeof input !== "object" || Array.isArray(input))) {
|
|
return { ok: false, error: "config must be an object" };
|
|
}
|
|
const supplied = input || {};
|
|
const out = {};
|
|
for (const f of fields) {
|
|
// Effective value: a non-empty supplied value wins, else fall back to base.
|
|
let v = supplied[f.key];
|
|
if (v == null || v === "") v = base[f.key];
|
|
|
|
if (v == null || v === "") {
|
|
if (f.default != null) {
|
|
out[f.key] = f.default;
|
|
continue;
|
|
}
|
|
if (f.required) return { ok: false, error: `${f.label} is required` };
|
|
continue;
|
|
}
|
|
if (f.type === "enum") {
|
|
if (!f.options.includes(v)) {
|
|
return { ok: false, error: `${f.label} must be one of: ${f.options.join(", ")}` };
|
|
}
|
|
} else {
|
|
if (typeof v !== "string") return { ok: false, error: `${f.label} must be a string` };
|
|
v = v.trim();
|
|
if (!v) {
|
|
if (f.required) return { ok: false, error: `${f.label} is required` };
|
|
continue;
|
|
}
|
|
}
|
|
out[f.key] = v;
|
|
}
|
|
return { ok: true, value: Object.keys(out).length ? out : null };
|
|
}
|
|
|
|
function validateHeaders(headers) {
|
|
if (headers == null) return { ok: true, value: null };
|
|
if (typeof headers !== "object" || Array.isArray(headers)) {
|
|
return { ok: false, error: "headers must be an object of string values" };
|
|
}
|
|
const out = {};
|
|
for (const [k, v] of Object.entries(headers)) {
|
|
if (typeof k !== "string" || !k.trim()) {
|
|
return { ok: false, error: "header names must be non-empty strings" };
|
|
}
|
|
if (typeof v !== "string") return { ok: false, error: `header "${k}" value must be a string` };
|
|
out[k] = v;
|
|
}
|
|
return { ok: true, value: Object.keys(out).length ? out : null };
|
|
}
|
|
|
|
function validateRuleIds(ruleIds) {
|
|
if (ruleIds == null) return { ok: true, value: null };
|
|
if (!Array.isArray(ruleIds)) return { ok: false, error: "rule_ids must be an array" };
|
|
for (const id of ruleIds) {
|
|
if (typeof id !== "string" || !id.trim()) {
|
|
return { ok: false, error: "rule_ids must be non-empty strings" };
|
|
}
|
|
}
|
|
return { ok: true, value: ruleIds.length ? ruleIds : null };
|
|
}
|
|
|
|
// ── Routes ──────────────────────────────────────────────────────────────────
|
|
|
|
// GET /api/webhooks/providers — redacted provider catalog for the UI
|
|
router.get("/providers", (_req, res) => {
|
|
res.json({ providers: publicProviders() });
|
|
});
|
|
|
|
// GET /api/webhooks — list targets (redacted)
|
|
router.get("/", (_req, res) => {
|
|
res.json({ targets: stmts.listWebhookTargets.all().map(serializeTarget) });
|
|
});
|
|
|
|
// POST /api/webhooks — create a target
|
|
router.post("/", (req, res) => {
|
|
const { name, type, url, enabled, secret, headers, rule_ids, config } = req.body || {};
|
|
|
|
if (!name || typeof name !== "string" || !name.trim()) return bad(res, "name is required");
|
|
if (!WEBHOOK_TYPES.includes(type)) {
|
|
return bad(res, `type must be one of: ${WEBHOOK_TYPES.join(", ")}`);
|
|
}
|
|
|
|
// URL: required for some providers, derived/defaulted for others (Telegram,
|
|
// Opsgenie, PagerDuty). Stored as "" when not user-supplied.
|
|
let storedUrl = "";
|
|
if (urlRequired(type)) {
|
|
const u = validateUrl(url, type);
|
|
if (!u.ok) return bad(res, u.error);
|
|
storedUrl = u.url;
|
|
} else if (url != null && String(url).trim()) {
|
|
const u = validateUrl(url, type);
|
|
if (!u.ok) return bad(res, u.error);
|
|
storedUrl = u.url;
|
|
}
|
|
|
|
const cfg = validateConfig(type, config);
|
|
if (!cfg.ok) return bad(res, cfg.error);
|
|
|
|
// secret + custom headers only apply to the generic family.
|
|
const generic = isGenericFamily(type);
|
|
const h = validateHeaders(generic ? headers : null);
|
|
if (!h.ok) return bad(res, h.error);
|
|
const r = validateRuleIds(rule_ids);
|
|
if (!r.ok) return bad(res, r.error);
|
|
|
|
let sec = null;
|
|
if (generic && secret != null) {
|
|
if (typeof secret !== "string") return bad(res, "secret must be a string");
|
|
sec = secret.trim() || null;
|
|
}
|
|
|
|
const id = uuidv4();
|
|
stmts.insertWebhookTarget.run(
|
|
id,
|
|
name.trim(),
|
|
type,
|
|
storedUrl,
|
|
enabled === false ? 0 : 1,
|
|
sec,
|
|
h.value ? JSON.stringify(h.value) : null,
|
|
r.value ? JSON.stringify(r.value) : null,
|
|
cfg.value ? JSON.stringify(cfg.value) : null
|
|
);
|
|
invalidateWebhookCache();
|
|
res.status(201).json({ target: serializeTarget(stmts.getWebhookTarget.get(id)) });
|
|
});
|
|
|
|
// PATCH /api/webhooks/:id — partial update. url/secret/headers/rule_ids/config
|
|
// are only changed when their key is present in the body (omit = leave as-is).
|
|
router.patch("/:id", (req, res) => {
|
|
const existing = stmts.getWebhookTarget.get(req.params.id);
|
|
if (!existing) {
|
|
return res
|
|
.status(404)
|
|
.json({ error: { code: "NOT_FOUND", message: "Webhook target not found" } });
|
|
}
|
|
const body = req.body || {};
|
|
const { name, url, enabled, secret, headers, rule_ids, config } = body;
|
|
const generic = isGenericFamily(existing.type);
|
|
|
|
if (name != null && (typeof name !== "string" || !name.trim())) {
|
|
return bad(res, "name must be a non-empty string");
|
|
}
|
|
|
|
let urlVal = null;
|
|
if (url != null) {
|
|
const u = validateUrl(url, existing.type);
|
|
if (!u.ok) return bad(res, u.error);
|
|
urlVal = u.url;
|
|
}
|
|
|
|
// config: merge supplied fields over the existing config, then re-validate,
|
|
// so e.g. region can change without re-sending the api_key.
|
|
let configSet = 0;
|
|
let configVal = null;
|
|
if ("config" in body) {
|
|
let base = {};
|
|
try {
|
|
base = existing.config ? JSON.parse(existing.config) : {};
|
|
} catch {
|
|
base = {};
|
|
}
|
|
const cfg = validateConfig(existing.type, config, base);
|
|
if (!cfg.ok) return bad(res, cfg.error);
|
|
configSet = 1;
|
|
configVal = cfg.value ? JSON.stringify(cfg.value) : null;
|
|
}
|
|
|
|
let secretSet = 0;
|
|
let secretVal = null;
|
|
if ("secret" in body && generic) {
|
|
if (secret !== null && typeof secret !== "string") {
|
|
return bad(res, "secret must be a string or null");
|
|
}
|
|
secretSet = 1;
|
|
secretVal = secret ? String(secret).trim() || null : null;
|
|
}
|
|
|
|
let headersSet = 0;
|
|
let headersVal = null;
|
|
if ("headers" in body && generic) {
|
|
const h = validateHeaders(headers);
|
|
if (!h.ok) return bad(res, h.error);
|
|
headersSet = 1;
|
|
headersVal = h.value ? JSON.stringify(h.value) : null;
|
|
}
|
|
|
|
let ruleSet = 0;
|
|
let ruleVal = null;
|
|
if ("rule_ids" in body) {
|
|
const r = validateRuleIds(rule_ids);
|
|
if (!r.ok) return bad(res, r.error);
|
|
ruleSet = 1;
|
|
ruleVal = r.value ? JSON.stringify(r.value) : null;
|
|
}
|
|
|
|
stmts.updateWebhookTarget.run(
|
|
name != null ? name.trim() : null,
|
|
urlVal,
|
|
enabled == null ? null : enabled ? 1 : 0,
|
|
secretSet,
|
|
secretVal,
|
|
headersSet,
|
|
headersVal,
|
|
ruleSet,
|
|
ruleVal,
|
|
configSet,
|
|
configVal,
|
|
req.params.id
|
|
);
|
|
invalidateWebhookCache();
|
|
res.json({ target: serializeTarget(stmts.getWebhookTarget.get(req.params.id)) });
|
|
});
|
|
|
|
// DELETE /api/webhooks/:id — delete a target (its delivery log cascades away)
|
|
router.delete("/:id", (req, res) => {
|
|
const existing = stmts.getWebhookTarget.get(req.params.id);
|
|
if (!existing) {
|
|
return res
|
|
.status(404)
|
|
.json({ error: { code: "NOT_FOUND", message: "Webhook target not found" } });
|
|
}
|
|
stmts.deleteWebhookTarget.run(req.params.id);
|
|
invalidateWebhookCache();
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// POST /api/webhooks/:id/test — send a synthetic alert and report the result.
|
|
// Always 200 (the request itself succeeded); `ok` carries the delivery result.
|
|
router.post("/:id/test", async (req, res) => {
|
|
const row = stmts.getWebhookTarget.get(req.params.id);
|
|
if (!row) {
|
|
return res
|
|
.status(404)
|
|
.json({ error: { code: "NOT_FOUND", message: "Webhook target not found" } });
|
|
}
|
|
const result = await sendTest(normalizeTarget(row));
|
|
res.json({
|
|
ok: result.ok,
|
|
status: result.status ?? null,
|
|
attempts: result.attempts,
|
|
error: result.error || null,
|
|
});
|
|
});
|
|
|
|
// GET /api/webhooks/:id/deliveries — recent delivery log for a target
|
|
router.get("/:id/deliveries", (req, res) => {
|
|
const row = stmts.getWebhookTarget.get(req.params.id);
|
|
if (!row) {
|
|
return res
|
|
.status(404)
|
|
.json({ error: { code: "NOT_FOUND", message: "Webhook target not found" } });
|
|
}
|
|
const limit = Math.max(1, Math.min(parseInt(req.query.limit, 10) || 20, 200));
|
|
const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
|
|
const deliveries = stmts.listWebhookDeliveriesForTarget.all(req.params.id, limit, offset);
|
|
res.json({ deliveries, limit, offset });
|
|
});
|
|
|
|
module.exports = router;
|
|
module.exports.WEBHOOK_TYPES = WEBHOOK_TYPES;
|