Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
265 changes: 265 additions & 0 deletions src/alerts/teams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
import type { AlertEvent, AlertSeverity } from "./types.js";
import { getLogger } from "../logging/index.js";
import { renderAlertTemplate } from "./templates.js";

const logger = getLogger().child({ component: "TeamsHandler" });
const TIMEOUT_MS = 10_000;

// ─── Teams Adaptive Card Color Palette ───────────────────────────────────────

type AdaptiveCardColor = "default" | "accent" | "good" | "warning" | "attention" | "dark" | "light";

function severityCardColor(event: AlertEvent): AdaptiveCardColor {
if (event.type === "alert_resolved") return "good";
if (event.severity === "critical") return "attention";
if (event.severity === "warning") return "warning";
return "good";
}

function severityEmoji(event: AlertEvent): string {
if (event.type === "alert_resolved") return "✅";
if (event.type === "state_changed") return "🔄";
if (event.severity === "critical") return "🔴";
return "⚠️";
}

function buildTitle(event: AlertEvent): string {
const icon = severityEmoji(event);
const contractDisplay = event.contractName ?? event.contractId;

if (event.type === "alert_resolved") {
return `${icon} Alert Resolved — ${contractDisplay}`;
}

if (event.type === "state_changed") {
const diffLabel = event.diff.diffType.charAt(0).toUpperCase() + event.diff.diffType.slice(1);
return `${icon} State ${diffLabel} — ${contractDisplay}`;
}

const level = event.severity === "critical" ? "CRITICAL" : "Warning";
return `${icon} TTL ${level} — ${contractDisplay}`;
}
Comment on lines +26 to +41

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

buildTitle mislabels resource_alert events as "TTL".

The default branch (Lines 39-40) is reached by both threshold_crossed and resource_alert events, always emitting TTL ${level}. A CPU/Memory resource alert (confirmed by the resource_alert test fixture in tests/alerts/teams.test.ts Lines 134-148, which has no entry/threshold fields) will render a title like ⚠️ TTL Warning — resource-contract, which is misleading for on-call responders triaging the alert.

🐛 Proposed fix
     if (event.type === "state_changed") {
         const diffLabel = event.diff.diffType.charAt(0).toUpperCase() + event.diff.diffType.slice(1);
         return `${icon} State ${diffLabel} — ${contractDisplay}`;
     }
 
+    if (event.type === "resource_alert") {
+        const resourceLabel = event.resource.type === "cpu" ? "CPU" : "Memory";
+        const level = event.severity === "critical" ? "CRITICAL" : "Warning";
+        return `${icon} ${resourceLabel} ${level} — ${contractDisplay}`;
+    }
+
     const level = event.severity === "critical" ? "CRITICAL" : "Warning";
     return `${icon} TTL ${level} — ${contractDisplay}`;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function buildTitle(event: AlertEvent): string {
const icon = severityEmoji(event);
const contractDisplay = event.contractName ?? event.contractId;
if (event.type === "alert_resolved") {
return `${icon} Alert Resolved — ${contractDisplay}`;
}
if (event.type === "state_changed") {
const diffLabel = event.diff.diffType.charAt(0).toUpperCase() + event.diff.diffType.slice(1);
return `${icon} State ${diffLabel} — ${contractDisplay}`;
}
const level = event.severity === "critical" ? "CRITICAL" : "Warning";
return `${icon} TTL ${level} — ${contractDisplay}`;
}
function buildTitle(event: AlertEvent): string {
const icon = severityEmoji(event);
const contractDisplay = event.contractName ?? event.contractId;
if (event.type === "alert_resolved") {
return `${icon} Alert Resolved — ${contractDisplay}`;
}
if (event.type === "state_changed") {
const diffLabel = event.diff.diffType.charAt(0).toUpperCase() + event.diff.diffType.slice(1);
return `${icon} State ${diffLabel} — ${contractDisplay}`;
}
if (event.type === "resource_alert") {
const resourceLabel = event.resource.type === "cpu" ? "CPU" : "Memory";
const level = event.severity === "critical" ? "CRITICAL" : "Warning";
return `${icon} ${resourceLabel} ${level} — ${contractDisplay}`;
}
const level = event.severity === "critical" ? "CRITICAL" : "Warning";
return `${icon} TTL ${level} — ${contractDisplay}`;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/alerts/teams.ts` around lines 26 - 41, Update buildTitle so
resource_alert events receive a resource-specific title rather than the TTL
label, while preserving the existing TTL title for threshold_crossed events and
the current resolved/state_changed behavior.


// ─── Adaptive Card Payload Builder ───────────────────────────────────────────

interface Fact {
title: string;
value: string;
}

interface AdaptiveCardPayload {
type: "message";
attachments: Array<{
contentType: "application/vnd.microsoft.card.adaptive";
contentUrl: null;
content: {
$schema: string;
type: "AdaptiveCard";
version: string;
body: Array<Record<string, unknown>>;
};
}>;
}

function buildAdaptiveCard(event: AlertEvent): AdaptiveCardPayload {
const contractDisplay = event.contractName ?? event.contractId;
const facts: Fact[] = [
{
title: "Contract",
value: contractDisplay,
},
{
title: "Network",
value: event.network,
},
];

if (event.type === "resource_alert") {
facts.push(
{
title: "Resource",
value: event.resource.type === "cpu" ? "CPU" : "Memory",
},
{
title: "Usage",
value: `${event.resource.usagePercent}% (${event.resource.currentUsage.toLocaleString()} / ${event.resource.limit.toLocaleString()})`,
},
{
title: "Severity",
value: event.severity.toUpperCase(),
},
);
} else if (event.type === "state_changed") {
facts.push(
{
title: "Entry",
value: event.entry.label ?? event.entry.type,
},
{
title: "Change Type",
value: event.diff.diffType,
},
{
title: "Old Value",
value: event.diff.oldValueXdr ?? "(none)",
},
{
title: "New Value",
value: event.diff.newValueXdr ?? "(none)",
},
);
} else {
facts.push(
{
title: "Entry",
value: event.entry.label ?? event.entry.type,
},
{
title: "Remaining TTL",
value: `${event.threshold.currentRemainingLedgers.toLocaleString()} ledgers (${event.threshold.approximateTimeRemaining})`,
},
{
title: "Alert Threshold",
value: `${event.threshold.configuredLedgers.toLocaleString()} ledgers`,
},
{
title: "Severity",
value: event.severity.toUpperCase(),
},
);
}

return {
type: "message",
attachments: [
{
contentType: "application/vnd.microsoft.card.adaptive",
contentUrl: null,
content: {
$schema: "http://adaptivecards.io/schemas/adaptive-card.json",
type: "AdaptiveCard",
version: "1.4",
body: [
{
type: "TextBlock",
size: "Large",
weight: "Bolder",
text: buildTitle(event),
color: severityCardColor(event),
wrap: true,
},
{
type: "FactSet",
facts,
},
{
type: "TextBlock",
text: `Run \`sorokeep status ${event.contractId}\` for details.`,
isSubtle: true,
size: "Small",
wrap: true,
},
],
},
},
],
};
}

// ─── Webhook URL Validation ───────────────────────────────────────────────────

function validateWebhookUrl(webhookUrl: string): void {
if (!webhookUrl) {
throw new Error(
"Teams webhook URL is required. " +
"Pass the full URL from your Microsoft Teams channel's Incoming Webhook settings.",
);
}

let parsed: URL;
try {
parsed = new URL(webhookUrl);
} catch {
throw new Error(
`Invalid Teams webhook URL: "${webhookUrl}". ` +
"Expected a URL like https://<tenant>.webhook.office.com/webhookb2/...",
);
}

if (!parsed.hostname.includes("webhook.office.com")) {
throw new Error(
`Invalid Teams webhook URL: "${webhookUrl}". ` +
"Expected a URL like https://<tenant>.webhook.office.com/webhookb2/...",
);
}
}
Comment on lines +171 to +195

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Hostname validation is bypassable — substring match instead of suffix match; no scheme check.

parsed.hostname.includes("webhook.office.com") accepts any hostname containing that substring anywhere, e.g. x.webhook.office.com.attacker.com, which is not a real Microsoft Teams endpoint. The PR objective requires validating hostnames as genuine *.webhook.office.com endpoints; a substring check doesn't enforce that. There's also no check that the scheme is https:, despite the error message advertising https://....

🛡️ Proposed fix
-    if (!parsed.hostname.includes("webhook.office.com")) {
+    const hostname = parsed.hostname.toLowerCase();
+    const isTeamsHost = hostname === "webhook.office.com" || hostname.endsWith(".webhook.office.com");
+    if (parsed.protocol !== "https:" || !isTeamsHost) {
         throw new Error(
             `Invalid Teams webhook URL: "${webhookUrl}". ` +
             "Expected a URL like https://<tenant>.webhook.office.com/webhookb2/...",
         );
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function validateWebhookUrl(webhookUrl: string): void {
if (!webhookUrl) {
throw new Error(
"Teams webhook URL is required. " +
"Pass the full URL from your Microsoft Teams channel's Incoming Webhook settings.",
);
}
let parsed: URL;
try {
parsed = new URL(webhookUrl);
} catch {
throw new Error(
`Invalid Teams webhook URL: "${webhookUrl}". ` +
"Expected a URL like https://<tenant>.webhook.office.com/webhookb2/...",
);
}
if (!parsed.hostname.includes("webhook.office.com")) {
throw new Error(
`Invalid Teams webhook URL: "${webhookUrl}". ` +
"Expected a URL like https://<tenant>.webhook.office.com/webhookb2/...",
);
}
}
function validateWebhookUrl(webhookUrl: string): void {
if (!webhookUrl) {
throw new Error(
"Teams webhook URL is required. " +
"Pass the full URL from your Microsoft Teams channel's Incoming Webhook settings.",
);
}
let parsed: URL;
try {
parsed = new URL(webhookUrl);
} catch {
throw new Error(
`Invalid Teams webhook URL: "${webhookUrl}". ` +
"Expected a URL like https://<tenant>.webhook.office.com/webhookb2/...",
);
}
const hostname = parsed.hostname.toLowerCase();
const isTeamsHost = hostname === "webhook.office.com" || hostname.endsWith(".webhook.office.com");
if (parsed.protocol !== "https:" || !isTeamsHost) {
throw new Error(
`Invalid Teams webhook URL: "${webhookUrl}". ` +
"Expected a URL like https://<tenant>.webhook.office.com/webhookb2/...",
);
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/alerts/teams.ts` around lines 171 - 195, Update validateWebhookUrl to
require the parsed URL to use the https: scheme and to validate the hostname as
a genuine *.webhook.office.com endpoint, using an exact hostname or strict
suffix check rather than includes. Preserve the existing required, parse-error,
and invalid-URL error behavior.


// ─── Public API ───────────────────────────────────────────────────────────────

export async function sendTeamsAlert(webhookUrl: string, event: AlertEvent): Promise<void> {
validateWebhookUrl(webhookUrl);

logger.debug(`Sending Teams alert to webhook`, {
type: event.type,
contractId: event.contractId,
severity: event.severity,
});

const customMessage = renderAlertTemplate("teams", event);
let payload: any;

if (customMessage !== null) {
try {
const parsed = JSON.parse(customMessage);
if (parsed && typeof parsed === "object") {
payload = parsed;
} else {
payload = {
type: "message",
text: customMessage,
};
}
} catch {
payload = {
type: "message",
text: customMessage,
};
}
} else {
payload = buildAdaptiveCard(event);
}

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);

let response: Response;
try {
response = await fetch(webhookUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}

if (!response.ok) {
let detail = "";
try {
const body = (await response.json()) as { message?: string; error?: { message?: string } };
if (body.message) {
detail = `: ${body.message}`;
} else if (body.error?.message) {
detail = `: ${body.error.message}`;
}
} catch {
// body not JSON — ignore
}
throw new Error(`Teams webhook request failed: HTTP ${response.status}${detail}`);
}
Comment on lines +249 to +262

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Error detail extraction misses the common { error: "<string>" } shape.

body.error?.message only handles a nested { error: { message } } object. Many webhook/API error bodies (including the test helper's { error: message } at tests/alerts/teams.test.ts Lines 41-46) use a plain string for error, so detail silently stays empty and useful diagnostic text is dropped from the thrown error.

🔧 Proposed fix
-            const body = (await response.json()) as { message?: string; error?: { message?: string } };
+            const body = (await response.json()) as { message?: string; error?: string | { message?: string } };
             if (body.message) {
                 detail = `: ${body.message}`;
+            } else if (typeof body.error === "string") {
+                detail = `: ${body.error}`;
             } else if (body.error?.message) {
                 detail = `: ${body.error.message}`;
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!response.ok) {
let detail = "";
try {
const body = (await response.json()) as { message?: string; error?: { message?: string } };
if (body.message) {
detail = `: ${body.message}`;
} else if (body.error?.message) {
detail = `: ${body.error.message}`;
}
} catch {
// body not JSON — ignore
}
throw new Error(`Teams webhook request failed: HTTP ${response.status}${detail}`);
}
if (!response.ok) {
let detail = "";
try {
const body = (await response.json()) as { message?: string; error?: string | { message?: string } };
if (body.message) {
detail = `: ${body.message}`;
} else if (typeof body.error === "string") {
detail = `: ${body.error}`;
} else if (body.error?.message) {
detail = `: ${body.error.message}`;
}
} catch {
// body not JSON — ignore
}
throw new Error(`Teams webhook request failed: HTTP ${response.status}${detail}`);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/alerts/teams.ts` around lines 249 - 262, Update the error-detail
extraction in the Teams webhook response handling to support both a
string-valued `body.error` and the existing nested `body.error.message` object
shape, preserving `body.message` precedence and including whichever diagnostic
text is available in the thrown error.


logger.debug(`Teams alert delivered successfully`);
}
Loading