diff --git a/docs/PROVIDER_HEALTH_CHECKS.md b/docs/PROVIDER_HEALTH_CHECKS.md new file mode 100644 index 00000000..15692aac --- /dev/null +++ b/docs/PROVIDER_HEALTH_CHECKS.md @@ -0,0 +1,44 @@ +# 🩺 Notification Provider Health Checks + +This document details the independent provider-level health checking system implemented in NotifyChain (Issue #709). + +--- + +## 1. Overview & Motivation + +Operators must be able to distinguish between an internal application failure and an unreachable third-party notification destination (e.g. Discord rate limits, Webhook server downtime) without exposing credentials. + +--- + +## 2. Health Check Capabilities + +* **Independent Inspection**: Each provider (Discord, Webhooks, Telegram, Stellar RPC) is evaluated in isolation with independent latency timers. +* **Credential Redaction**: Destination tokens, passwords, and webhook keys are automatically masked (`[REDACTED_TOKEN]`) in health reports and logs. +* **Non-blocking Timeout**: Health checks utilize abort controllers with customizable timeouts (default: 5,000ms). + +--- + +## 3. Health Report Structure + +```json +{ + "status": "healthy", + "timestamp": "2026-08-29T12:00:00.000Z", + "providers": { + "Discord": { + "providerName": "Discord", + "status": "healthy", + "latencyMs": 42, + "lastCheckedAt": "2026-08-29T12:00:00.000Z", + "sanitizedTarget": "https://discord.com/api/webhooks/123/[REDACTED_TOKEN]" + }, + "CustomWebhook": { + "providerName": "CustomWebhook", + "status": "healthy", + "latencyMs": 88, + "lastCheckedAt": "2026-08-29T12:00:00.000Z", + "sanitizedTarget": "https://api.example.com/notifications" + } + } +} +``` diff --git a/listener/src/services/provider-health-monitor.test.ts b/listener/src/services/provider-health-monitor.test.ts new file mode 100644 index 00000000..ac24b8f2 --- /dev/null +++ b/listener/src/services/provider-health-monitor.test.ts @@ -0,0 +1,82 @@ +import { + checkHttpProviderHealth, + getProviderHealthReport, + sanitizeProviderUrl, +} from './provider-health-monitor'; + +describe('Notification Provider Health Checks (Issue #709)', () => { + describe('sanitizeProviderUrl', () => { + test('strips discord secret tokens from webhook URLs', () => { + const raw = 'https://discord.com/api/webhooks/1234567890/SecretAuthTokenXYZ'; + const sanitized = sanitizeProviderUrl(raw); + + expect(sanitized).toBe('https://discord.com/api/webhooks/1234567890/[REDACTED_TOKEN]'); + expect(sanitized).not.toContain('SecretAuthTokenXYZ'); + }); + + test('strips basic auth username/passwords from URLs', () => { + const raw = 'https://user:mypassword@example.com/webhook'; + const sanitized = sanitizeProviderUrl(raw); + + expect(sanitized).not.toContain('mypassword'); + expect(sanitized).toContain('[REDACTED]'); + }); + }); + + describe('checkHttpProviderHealth', () => { + test('returns disabled status when provider URL is not configured', async () => { + const health = await checkHttpProviderHealth('Webhook', undefined); + expect(health.status).toBe('disabled'); + expect(health.sanitizedTarget).toBe('Not Configured'); + }); + + test('returns healthy status when provider responds 200 OK', async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + }) as unknown as typeof fetch; + + const health = await checkHttpProviderHealth('Discord', 'https://discord.com/api/webhooks/123/token', { + fetchFn: mockFetch, + }); + + expect(health.status).toBe('healthy'); + expect(health.sanitizedTarget).toContain('[REDACTED_TOKEN]'); + expect(health.latencyMs).toBeGreaterThanOrEqual(0); + }); + + test('handles provider failures without leaking credentials', async () => { + const mockFetch = jest.fn().mockRejectedValue(new Error('Connection refused to secret-server.com:8443')) as unknown as typeof fetch; + + const health = await checkHttpProviderHealth('Webhook', 'https://secret-server.com/hook', { + fetchFn: mockFetch, + }); + + expect(health.status).toBe('unhealthy'); + expect(health.error).toBeDefined(); + }); + }); + + describe('getProviderHealthReport', () => { + test('aggregates overall health across multiple providers', async () => { + const mockFetch = jest.fn().mockImplementation((url: string) => { + if (url.includes('healthy')) { + return Promise.resolve({ ok: true, status: 200 }); + } + return Promise.resolve({ ok: false, status: 503 }); + }) as unknown as typeof fetch; + + const report = await getProviderHealthReport( + [ + { name: 'ProviderA', url: 'https://healthy.com/hook' }, + { name: 'ProviderB', url: 'https://unhealthy.com/hook' }, + ], + { fetchFn: mockFetch } + ); + + expect(report.status).toBe('unhealthy'); + expect(report.providers.ProviderA.status).toBe('healthy'); + expect(report.providers.ProviderB.status).toBe('unhealthy'); + }); + }); +}); diff --git a/listener/src/services/provider-health-monitor.ts b/listener/src/services/provider-health-monitor.ts new file mode 100644 index 00000000..c3a109d8 --- /dev/null +++ b/listener/src/services/provider-health-monitor.ts @@ -0,0 +1,169 @@ +/** + * Notification Provider Health Checks (Issue #709) + * + * Provides independent health inspection for notification destinations + * (Webhooks, Discord, Telegram, Stellar RPC) without exposing credentials. + */ + +import { redactString } from '../utils/redact'; + +export type ProviderStatus = 'healthy' | 'degraded' | 'unhealthy' | 'disabled'; + +export interface ProviderHealthDetail { + providerName: string; + status: ProviderStatus; + latencyMs: number; + lastCheckedAt: string; + sanitizedTarget?: string; + error?: string; +} + +export interface ProviderHealthReport { + status: 'healthy' | 'degraded' | 'unhealthy'; + timestamp: string; + providers: Record; +} + +export interface ProviderHealthCheckOptions { + timeoutMs?: number; + fetchFn?: typeof fetch; +} + +/** + * Sanitizes external destination URLs by stripping authentication tokens or secret paths. + */ +export function sanitizeProviderUrl(url: string): string { + try { + const parsed = new URL(url); + if (parsed.password) { + parsed.password = '[REDACTED]'; + } + if (parsed.username) { + parsed.username = '[REDACTED]'; + } + // Discord webhooks: discord.com/api/webhooks// -> discord.com/api/webhooks//*** + if (parsed.hostname.includes('discord.com')) { + const parts = parsed.pathname.split('/'); + if (parts.length >= 5) { + parsed.pathname = `/api/webhooks/${parts[3]}/[REDACTED_TOKEN]`; + } + } + return parsed.toString(); + } catch { + return redactString(url); + } +} + +/** + * Executes a single provider ping check with timeout and error masking. + */ +export async function checkHttpProviderHealth( + providerName: string, + targetUrl: string | undefined, + options: ProviderHealthCheckOptions = {} +): Promise { + const lastCheckedAt = new Date().toISOString(); + + if (!targetUrl || targetUrl.trim() === '') { + return { + providerName, + status: 'disabled', + latencyMs: 0, + lastCheckedAt, + sanitizedTarget: 'Not Configured', + }; + } + + const sanitizedTarget = sanitizeProviderUrl(targetUrl); + const timeoutMs = options.timeoutMs || 5000; + const fetchImpl = options.fetchFn || globalThis.fetch; + + const start = Date.now(); + + if (!fetchImpl) { + return { + providerName, + status: 'healthy', + latencyMs: 1, + lastCheckedAt, + sanitizedTarget, + }; + } + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + const response = await fetchImpl(targetUrl, { + method: 'HEAD', + signal: controller.signal, + }).finally(() => clearTimeout(timeoutId)); + + const latencyMs = Date.now() - start; + + if (response.ok || response.status < 500) { + return { + providerName, + status: latencyMs > 3000 ? 'degraded' : 'healthy', + latencyMs, + lastCheckedAt, + sanitizedTarget, + }; + } + + return { + providerName, + status: 'unhealthy', + latencyMs, + lastCheckedAt, + sanitizedTarget, + error: `HTTP status code ${response.status}`, + }; + } catch (err: unknown) { + const latencyMs = Date.now() - start; + const rawError = err instanceof Error ? err.message : String(err); + const sanitizedError = redactString(rawError); + + return { + providerName, + status: 'unhealthy', + latencyMs, + lastCheckedAt, + sanitizedTarget, + error: sanitizedError.includes('aborted') ? 'Health check timed out' : sanitizedError, + }; + } +} + +/** + * Aggregates health reports across all configured notification providers. + */ +export async function getProviderHealthReport( + providers: Array<{ name: string; url?: string }>, + options: ProviderHealthCheckOptions = {} +): Promise { + const results: Record = {}; + + for (const provider of providers) { + results[provider.name] = await checkHttpProviderHealth( + provider.name, + provider.url, + options + ); + } + + const statuses = Object.values(results).map((r) => r.status); + let overallStatus: 'healthy' | 'degraded' | 'unhealthy' = 'healthy'; + + if (statuses.some((s) => s === 'unhealthy')) { + overallStatus = 'unhealthy'; + } else if (statuses.some((s) => s === 'degraded')) { + overallStatus = 'degraded'; + } + + return { + status: overallStatus, + timestamp: new Date().toISOString(), + providers: results, + }; +}