/**
* Built-in notification channels.
*
* Webhook and Slack are always registered. Delivery uses (in order):
* 1. per-notification `metadata.webhookUrl` / `metadata.slackWebhookUrl`
* 2. the matching `NOTIFICATIONS_*` env URL (workspace default)
* If neither is set, that channel no-ops for the emission.
*
* Extra channels can be registered at any time via
* `registerNotificationChannel()` from a server plugin.
*
* NOTIFICATIONS_WEBHOOK_URL → default POST JSON URL when metadata omits one.
* Supports `${keys.NAME}` substitution — the raw
* value never enters the agent context.
* NOTIFICATIONS_WEBHOOK_AUTH → optional `Authorization` header value (also
* supports `${keys.NAME}`).
* NOTIFICATIONS_SLACK_WEBHOOK_URL
* → default Slack incoming webhook when metadata
* omits `slackWebhookUrl`.
* Supports `${keys.NAME}` substitution.
* NOTIFICATIONS_EMAIL_CHANNEL=1
* → enable the built-in email channel for
* per-notification recipients.
* NOTIFICATIONS_EMAIL_RECIPIENTS
* → comma-separated fallback recipients for email
* notifications that do not pass
* `metadata.emailRecipients`.
*/
import { ssrfSafeFetch } from "../extensions/url-safety.js";
import { escapeSlackMrkdwn } from "../integrations/webhook-delivery.js";
import {
getKeyAllowlist,
getResolvedKeyAllowlist,
resolveKeyReferencesWithRequestScopes,
validateUrlAllowlist,
} from "../secrets/substitution.js";
import { sendEmail } from "../server/email.js";
import { registerNotificationChannel } from "./registry.js";
import type { NotificationChannel, NotificationInput } from "./types.js";
let _registered = false;
export function registerBuiltinNotificationChannels(): void {
if (_registered) return;
_registered = true;
registerNotificationChannel(
createWebhookChannel(process.env.NOTIFICATIONS_WEBHOOK_URL),
);
registerNotificationChannel(
createSlackWebhookChannel(process.env.NOTIFICATIONS_SLACK_WEBHOOK_URL),
);
// Email is always registered so per-notification `metadata.emailRecipients`
// work without a workspace-wide env flag (same pattern as webhook/slack).
registerNotificationChannel(createEmailChannel());
}
function createWebhookChannel(
envUrlTemplate: string | undefined,
): NotificationChannel {
const authTemplate = process.env.NOTIFICATIONS_WEBHOOK_AUTH;
return {
name: "webhook",
async deliver(input, meta) {
const overrideUrlTemplate = deliveryMetadataString(
input.metadata,
"webhookUrl",
);
const urlTemplate =
overrideUrlTemplate ??
metadataString(input.metadata, "webhookUrl") ??
envUrlTemplate?.trim();
// No-op when neither a per-notification nor workspace URL is set —
// mirrors email's empty-recipients behavior so notify-all stays quiet.
if (!urlTemplate) return false;
const { url, headers, assertUrlAllowed } = await resolveWebhookRequest(
urlTemplate,
overrideUrlTemplate ? undefined : authTemplate,
meta.owner,
"webhook",
);
const res = await ssrfSafeFetch(
url,
{
method: "POST",
headers,
body: JSON.stringify({
severity: input.severity,
title: input.title,
body: input.body,
metadata: scrubDeliveryMetadata(input.metadata),
owner: meta.owner,
emittedAt: new Date().toISOString(),
}),
},
{ maxRedirects: 3, assertUrlAllowed },
);
if (!res.ok) {
throw new Error(
`[notifications] webhook ${new URL(url).origin} returned ${res.status}${
(await readErrorSnippet(res)) || ""
}`,
);
}
},
};
}
function createSlackWebhookChannel(
envUrlTemplate: string | undefined,
): NotificationChannel {
const authTemplate = process.env.NOTIFICATIONS_SLACK_WEBHOOK_AUTH;
return {
name: "slack",
async deliver(input, meta) {
const overrideUrlTemplate = deliveryMetadataString(
input.metadata,
"slackWebhookUrl",
);
const urlTemplate =
overrideUrlTemplate ??
metadataString(input.metadata, "slackWebhookUrl") ??
envUrlTemplate?.trim();
if (!urlTemplate) return false;
const { url, headers, assertUrlAllowed } = await resolveWebhookRequest(
urlTemplate,
overrideUrlTemplate ? undefined : authTemplate,
meta.owner,
"Slack webhook",
);
const res = await ssrfSafeFetch(
url,
{
method: "POST",
headers,
body: JSON.stringify({
text: slackText(input.severity, input.title, input.body),
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: `*${escapeSlackMrkdwn(input.title)}*`,
},
},
...(input.body
? [
{
type: "section",
text: {
type: "mrkdwn",
text: escapeSlackMrkdwn(input.body),
},
},
]
: []),
{
type: "context",
elements: [
{
type: "mrkdwn",
text: `Severity: \`${input.severity}\` Owner: ${escapeSlackMrkdwn(meta.owner)}`,
},
],
},
],
}),
},
{ maxRedirects: 3, assertUrlAllowed },
);
if (!res.ok) {
throw new Error(
`[notifications] Slack webhook ${new URL(url).origin} returned ${res.status}${
(await readErrorSnippet(res)) || ""
}`,
);
}
},
};
}
function createEmailChannel(): NotificationChannel {
return {
name: "email",
async deliver(input) {
const recipients = notificationEmailRecipients(input.metadata);
if (recipients.length === 0) return false;
const subject =
typeof input.metadata?.emailSubject === "string" &&
input.metadata.emailSubject.trim()
? input.metadata.emailSubject.trim()
: `[${input.severity}] ${input.title}`;
const text = `${input.title}\n\n${input.body ?? ""}`;
const html = [
`
${escapeHtml(input.title)}
`,
input.body ? `${escapeHtml(input.body)}
` : "",
].join("");
await Promise.all(
recipients.map((to) =>
sendEmail({
to,
subject,
text,
html,
}),
),
);
},
};
}
async function resolveWebhookRequest(
urlTemplate: string,
authTemplate: string | undefined,
owner: string,
label: string,
): Promise<{
url: string;
headers: Record;
assertUrlAllowed: (url: string) => void;
}> {
// Resolve `${keys.NAME}` references through the same request-scope
// cascade already used by extension fetches (extensions/routes.ts) and
// automation connector headers (automation/index.ts): user scope first
// (a personal override always wins), then the active org scope — where
// the Dispatch vault syncs workspace secrets — then workspace scope.
// Org/workspace vault rows are write-gated (org-admin + Dispatch vault
// UI), so reading them here is safe by default; this is unrelated to the
// opt-in-only user→workspace fallback in resolveKeyReferences (audit 05
// H2 in secrets/substitution.ts), which stays off. In headless contexts
// (e.g. a scheduled monitor check) getRequestOrgId() may be unset, in
// which case the cascade checks user + solo-workspace scopes only — a
// strict superset of the previous user-scope-only behavior.
// Missing keys throw — the error surfaces in logs and the channel is marked
// un-delivered, but other channels still run.
const urlResult = await resolveKeyReferencesWithRequestScopes(
urlTemplate,
owner,
);
const url = urlResult.resolved;
const headers: Record = {
"Content-Type": "application/json",
};
let authResult:
| Awaited>
| undefined;
if (authTemplate) {
authResult = await resolveKeyReferencesWithRequestScopes(
authTemplate,
owner,
);
headers.Authorization = authResult.resolved;
}
// If the user set an allowlist on a referenced key, enforce it here —
// origin-level check, same rule the automations fetch-tool applies.
// Validate URL and Authorization keys independently so an allowlisted auth
// token cannot be sent to a metadata-provided destination. Keep scope in the
// dedupe key because the same key name can resolve at a different scope if a
// credential changes between the two substitutions.
type ResolvedKey = NonNullable[number];
const keyUsages = new Map<
string,
{ name: string; resolvedKey?: ResolvedKey }
>();
for (const result of authResult ? [urlResult, authResult] : [urlResult]) {
for (const name of result.usedKeys) {
const resolved = (result.resolvedKeys ?? []).filter(
(ref) => ref.name === name,
);
if (resolved.length === 0) {
keyUsages.set(`user:${owner}:${name}`, { name });
continue;
}
for (const resolvedKey of resolved) {
keyUsages.set(`${resolvedKey.scope}:${resolvedKey.scopeId}:${name}`, {
name,
resolvedKey,
});
}
}
}
const usages = Array.from(keyUsages.values());
const allowlists = await Promise.all(
usages.map(({ name, resolvedKey }) =>
resolvedKey
? getResolvedKeyAllowlist(resolvedKey)
: getKeyAllowlist(name, "user", owner),
),
);
const assertUrlAllowed = (candidateUrl: string): void => {
usages.forEach(({ name }, i) => {
if (!validateUrlAllowlist(candidateUrl, allowlists[i])) {
throw new Error(
`[notifications] ${label} URL ${new URL(candidateUrl).origin} is not in the allowlist for key "${name}"`,
);
}
});
};
assertUrlAllowed(url);
return { url, headers, assertUrlAllowed };
}
function metadataString(
metadata: NotificationInput["metadata"],
key: string,
): string | undefined {
const value = metadata?.[key];
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed || undefined;
}
function deliveryMetadataString(
metadata: NotificationInput["metadata"],
key: string,
): string | undefined {
const delivery = metadata?.delivery;
if (!delivery || typeof delivery !== "object" || Array.isArray(delivery)) {
return undefined;
}
const value = (delivery as Record)[key];
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed || undefined;
}
function scrubDeliveryMetadata(
metadata: Record | undefined,
): Record | undefined {
if (!metadata) return undefined;
const entries = Object.entries(metadata).filter(
([key]) =>
key !== "delivery" && key !== "webhookUrl" && key !== "slackWebhookUrl",
);
return entries.length ? Object.fromEntries(entries) : undefined;
}
function notificationEmailRecipients(
metadata: Record | undefined,
): string[] {
const fromMetadata = stringArray(metadata?.emailRecipients);
const fromEnv = commaList(process.env.NOTIFICATIONS_EMAIL_RECIPIENTS);
const seen = new Set();
const recipients: string[] = [];
for (const raw of [...fromMetadata, ...fromEnv]) {
const email = raw.trim().toLowerCase();
if (!email || seen.has(email)) continue;
seen.add(email);
recipients.push(email);
}
return recipients;
}
function stringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter((item): item is string => typeof item === "string");
}
function commaList(value: string | undefined): string[] {
return value
? value
.split(",")
.map((item) => item.trim())
.filter(Boolean)
: [];
}
function slackText(
severity: string,
title: string,
body: string | undefined,
): string {
return `[${severity}] ${title}${body ? `\n${body}` : ""}`;
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """);
}
/**
* Read up to ~1 KB from the body for error context. Streams chunks so a
* misbehaving endpoint returning a large error page doesn't pin that whole
* payload in memory per failed webhook.
*/
async function readErrorSnippet(res: Response): Promise {
const reader = res.body?.getReader();
if (!reader) return "";
const decoder = new TextDecoder();
const MAX = 1024;
let buf = "";
try {
while (buf.length < MAX) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
}
reader.cancel().catch(() => {});
} catch {
return "";
}
if (!buf) return "";
return `: ${buf.slice(0, 200)}`;
}