diff --git a/backend/src/api/routes/alertRoutingAdmin.ts b/backend/src/api/routes/alertRoutingAdmin.ts index 85d8e045..f2de2921 100644 --- a/backend/src/api/routes/alertRoutingAdmin.ts +++ b/backend/src/api/routes/alertRoutingAdmin.ts @@ -1,6 +1,7 @@ import type { FastifyInstance } from "fastify"; import { authMiddleware } from "../middleware/auth.js"; import { alertRoutingService } from "../../services/alertRouting.service.js"; +import type { RoutingSeverity } from "../../services/alertRouting.service.js"; import { createAlertRoutingRuleSchema, listAlertRoutingAuditQuerySchema, @@ -8,6 +9,13 @@ import { updateAlertRoutingRuleSchema, } from "../validations/alertRouting.schema.js"; +const VALID_SEVERITIES = new Set(["critical", "high", "medium", "low"]); + +function parseSeverity(value: string): RoutingSeverity { + if (VALID_SEVERITIES.has(value)) return value as RoutingSeverity; + return "medium"; +} + export async function alertRoutingAdminRoutes(server: FastifyInstance) { const requireAdmin = authMiddleware({ requiredScopes: ["admin:api-keys"] }); @@ -205,4 +213,173 @@ export async function alertRoutingAdminRoutes(server: FastifyInstance) { return { entries }; } ); + + server.post( + "/simulate", + { + preHandler: requireAdmin, + schema: { + tags: ["Config"], + summary: "Dry-run alert routing simulation (no dispatches)", + description: + "Evaluates which active routing rules would match a simulated alert and which channels would fire, without dispatching anything to real endpoints.", + security: [{ ApiKeyAuth: [] }], + body: { + type: "object", + required: ["severity"], + additionalProperties: false, + properties: { + severity: { + type: "string", + enum: ["critical", "high", "medium", "low"], + }, + assetCode: { type: "string", maxLength: 20 }, + sourceType: { type: "string", maxLength: 80 }, + ownerAddress: { type: "string" }, + label: { type: "string", maxLength: 120 }, + triggeredValue: { type: "number" }, + threshold: { type: "number" }, + metric: { type: "string", maxLength: 80 }, + }, + }, + }, + }, + async (request, reply) => { + const body = request.body as { + severity: string; + assetCode?: string; + sourceType?: string; + ownerAddress?: string; + label?: string; + triggeredValue?: number; + threshold?: number; + metric?: string; + }; + + if (!VALID_SEVERITIES.has(body.severity)) { + return reply.code(400).send({ error: "Invalid severity value" }); + } + + const severity = parseSeverity(body.severity); + const assetCode = (body.assetCode ?? "").trim().toUpperCase(); + const sourceType = (body.sourceType ?? "").trim(); + + // Load rules — scope by ownerAddress if provided + const allRules = await alertRoutingService.listRules(body.ownerAddress); + const activeRules = allRules.filter((rule) => rule.isActive); + const inactiveRules = allRules.filter((rule) => !rule.isActive); + + // Evaluate each active rule in priority order + const ruleResults = activeRules + .slice() + .sort((a, b) => a.priorityOrder - b.priorityOrder) + .map((rule) => { + const severityMatch = + rule.severityLevels.length === 0 || + rule.severityLevels.includes(severity); + + const assetMatch = + rule.assetCodes.length === 0 || + (assetCode !== "" && + rule.assetCodes + .map((c) => c.toUpperCase()) + .includes(assetCode)); + + const sourceMatch = + rule.sourceTypes.length === 0 || + (sourceType !== "" && rule.sourceTypes.includes(sourceType)); + + const matched = severityMatch && assetMatch && sourceMatch; + + const reasons: string[] = []; + + if (rule.severityLevels.length === 0) { + reasons.push("Severity: matches any (no filter set)"); + } else if (severityMatch) { + reasons.push( + `Severity: "${severity}" is in [${rule.severityLevels.join(", ")}]` + ); + } else { + reasons.push( + `Severity: "${severity}" not in [${rule.severityLevels.join(", ")}] — no match` + ); + } + + if (rule.assetCodes.length === 0) { + reasons.push("Asset: matches any (no filter set)"); + } else if (assetMatch) { + reasons.push( + `Asset: "${assetCode}" is in [${rule.assetCodes.join(", ")}]` + ); + } else { + reasons.push( + `Asset: "${assetCode || "(empty)"}" not in [${rule.assetCodes.join(", ")}] — no match` + ); + } + + if (rule.sourceTypes.length === 0) { + reasons.push("Source type: matches any (no filter set)"); + } else if (sourceMatch) { + reasons.push( + `Source type: "${sourceType}" is in [${rule.sourceTypes.join(", ")}]` + ); + } else { + reasons.push( + `Source type: "${sourceType || "(empty)"}" not in [${rule.sourceTypes.join(", ")}] — no match` + ); + } + + return { + ruleId: rule.id, + ruleName: rule.name, + priorityOrder: rule.priorityOrder, + ownerAddress: rule.ownerAddress, + matched, + reasons, + channels: matched ? rule.channels : [], + fallbackChannels: matched ? rule.fallbackChannels : [], + suppressionWindowSeconds: rule.suppressionWindowSeconds, + }; + }); + + const matchedResults = ruleResults.filter((r) => r.matched); + const firstMatch = matchedResults[0] ?? null; + + const simulationId = `sim_${Date.now()}_${Math.random() + .toString(36) + .slice(2, 8)}`; + + return reply.send({ + simulationId, + timestamp: new Date().toISOString(), + input: { + severity, + assetCode, + sourceType, + ownerAddress: body.ownerAddress ?? null, + label: body.label ?? null, + triggeredValue: body.triggeredValue ?? null, + threshold: body.threshold ?? null, + metric: body.metric ?? null, + }, + results: ruleResults, + skippedInactive: inactiveRules.map((r) => ({ + ruleId: r.id, + ruleName: r.name, + priorityOrder: r.priorityOrder, + })), + summary: { + totalActiveRules: activeRules.length, + totalMatched: matchedResults.length, + firstMatchingRule: firstMatch + ? { ruleId: firstMatch.ruleId, ruleName: firstMatch.ruleName } + : null, + wouldDispatch: firstMatch !== null, + effectiveChannels: firstMatch?.channels ?? [], + effectiveFallbackChannels: firstMatch?.fallbackChannels ?? [], + suppressionWindowSeconds: firstMatch?.suppressionWindowSeconds ?? 0, + }, + }); + } + ); } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ac16a8f8..d2a2bac8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -28,6 +28,7 @@ const RelationshipExplorer = lazy(() => import("./pages/RelationshipExplorer")); const SearchResultsPage = lazy(() => import("./pages/SearchResultsPage")); const Alerts = lazy(() => import("./pages/Alerts")); const DataProvenanceGraph = lazy(() => import("./pages/DataProvenanceGraph")); +const AlertSimulationSandbox = lazy(() => import("./pages/AlertSimulationSandbox")); function NotificationInitializer() { useNotifications(); @@ -72,6 +73,7 @@ function App() { } /> } /> } /> + } /> diff --git a/frontend/src/components/MobileNav/navigation.ts b/frontend/src/components/MobileNav/navigation.ts index 231349d7..dbf196d0 100644 --- a/frontend/src/components/MobileNav/navigation.ts +++ b/frontend/src/components/MobileNav/navigation.ts @@ -37,6 +37,11 @@ export const navGroups: NavGroup[] = [ label: "Alert Routing", description: "Manage alert dispatch routing and audit", }, + { + to: "/alert-sandbox", + label: "Alert Sandbox", + description: "Dry-run alert rules against synthetic data before enabling in production", + }, { to: "/settings", label: "Settings", description: "Notification and dashboard preferences" }, ], }, diff --git a/frontend/src/hooks/useAlertSimulation.ts b/frontend/src/hooks/useAlertSimulation.ts new file mode 100644 index 00000000..6650bc78 --- /dev/null +++ b/frontend/src/hooks/useAlertSimulation.ts @@ -0,0 +1,146 @@ +import { useState, useCallback } from "react"; + +export type SimulationSeverity = "critical" | "high" | "medium" | "low"; + +export interface SimulationInput { + severity: SimulationSeverity; + assetCode: string; + sourceType: string; + ownerAddress: string; + label: string; + triggeredValue: number | null; + threshold: number | null; + metric: string; +} + +export interface SimulationRuleResult { + ruleId: string; + ruleName: string; + priorityOrder: number; + ownerAddress: string | null; + matched: boolean; + reasons: string[]; + channels: string[]; + fallbackChannels: string[]; + suppressionWindowSeconds: number; +} + +export interface SimulationSummary { + totalActiveRules: number; + totalMatched: number; + firstMatchingRule: { ruleId: string; ruleName: string } | null; + wouldDispatch: boolean; + effectiveChannels: string[]; + effectiveFallbackChannels: string[]; + suppressionWindowSeconds: number; +} + +export interface SimulationResult { + simulationId: string; + timestamp: string; + input: SimulationInput & { + ownerAddress: string | null; + label: string | null; + triggeredValue: number | null; + threshold: number | null; + metric: string | null; + }; + results: SimulationRuleResult[]; + skippedInactive: { ruleId: string; ruleName: string; priorityOrder: number }[]; + summary: SimulationSummary; +} + +const HISTORY_KEY = "bw_sim_history"; +const MAX_HISTORY = 20; + +function loadHistory(): SimulationResult[] { + try { + const raw = localStorage.getItem(HISTORY_KEY); + return raw ? (JSON.parse(raw) as SimulationResult[]) : []; + } catch { + return []; + } +} + +function persistHistory(items: SimulationResult[]): void { + try { + localStorage.setItem(HISTORY_KEY, JSON.stringify(items.slice(0, MAX_HISTORY))); + } catch { + // ignore quota errors + } +} + +export function useAlertSimulation(adminToken: string) { + const [isRunning, setIsRunning] = useState(false); + const [error, setError] = useState(null); + const [currentResult, setCurrentResult] = useState(null); + const [history, setHistory] = useState(loadHistory); + + const runSimulation = useCallback( + async (input: SimulationInput) => { + setIsRunning(true); + setError(null); + + try { + const payload: Record = { severity: input.severity }; + if (input.assetCode.trim()) payload.assetCode = input.assetCode.trim(); + if (input.sourceType.trim()) payload.sourceType = input.sourceType.trim(); + if (input.ownerAddress.trim()) payload.ownerAddress = input.ownerAddress.trim(); + if (input.label.trim()) payload.label = input.label.trim(); + if (input.metric.trim()) payload.metric = input.metric.trim(); + if (input.triggeredValue !== null) payload.triggeredValue = input.triggeredValue; + if (input.threshold !== null) payload.threshold = input.threshold; + + const res = await fetch("/api/v1/admin/alert-routing/simulate", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-API-Key": adminToken, + }, + body: JSON.stringify(payload), + }); + + if (!res.ok) { + const data = (await res.json()) as { error?: unknown }; + throw new Error( + typeof data.error === "string" + ? data.error + : `HTTP ${res.status}` + ); + } + + const result = (await res.json()) as SimulationResult; + setCurrentResult(result); + setHistory((prev) => { + const next = [result, ...prev].slice(0, MAX_HISTORY); + persistHistory(next); + return next; + }); + } catch (err) { + setError(err instanceof Error ? err.message : "Simulation failed"); + } finally { + setIsRunning(false); + } + }, + [adminToken] + ); + + const restoreFromHistory = useCallback((result: SimulationResult) => { + setCurrentResult(result); + }, []); + + const clearHistory = useCallback(() => { + setHistory([]); + persistHistory([]); + }, []); + + return { + isRunning, + error, + currentResult, + history, + runSimulation, + restoreFromHistory, + clearHistory, + }; +} diff --git a/frontend/src/pages/AlertSimulationSandbox.tsx b/frontend/src/pages/AlertSimulationSandbox.tsx new file mode 100644 index 00000000..583419f7 --- /dev/null +++ b/frontend/src/pages/AlertSimulationSandbox.tsx @@ -0,0 +1,872 @@ +import { useState } from "react"; +import { + useAlertSimulation, + type SimulationInput, + type SimulationResult, + type SimulationSeverity, +} from "../hooks/useAlertSimulation"; + +// ─── Constants ─────────────────────────────────────────────────────────────── + +const SEVERITY_STYLES: Record = { + critical: { badge: "bg-red-900/50 text-red-400 border border-red-700", dot: "bg-red-500" }, + high: { badge: "bg-orange-900/50 text-orange-400 border border-orange-700", dot: "bg-orange-500" }, + medium: { badge: "bg-yellow-900/50 text-yellow-400 border border-yellow-700", dot: "bg-yellow-500" }, + low: { badge: "bg-blue-900/50 text-blue-400 border border-blue-700", dot: "bg-blue-500" }, +}; + +const CHANNEL_LABELS: Record = { + in_app: "In-App", + webhook: "Webhook", + email: "Email", +}; + +const PRESETS = [ + { + id: "critical_bridge", + label: "Critical Bridge Failure", + description: "Complete bridge outage, funds at risk", + input: { + severity: "critical" as SimulationSeverity, + assetCode: "USDC", + sourceType: "bridge", + metric: "bridge_health", + triggeredValue: 0, + threshold: 100, + }, + }, + { + id: "token_exploit", + label: "Token Exploit", + description: "Security vulnerability detected", + input: { + severity: "critical" as SimulationSeverity, + assetCode: "ETH", + sourceType: "security", + metric: "exploit_severity", + triggeredValue: 1, + threshold: 0, + }, + }, + { + id: "tvl_anomaly", + label: "TVL Anomaly", + description: "Significant total value locked drop", + input: { + severity: "high" as SimulationSeverity, + assetCode: "WBTC", + sourceType: "analytics", + metric: "tvl_usd", + triggeredValue: 8500000, + threshold: 10000000, + }, + }, + { + id: "reserve_drift", + label: "Reserve Backing Drift", + description: "Collateral ratio below threshold", + input: { + severity: "high" as SimulationSeverity, + assetCode: "USDT", + sourceType: "reconciliation", + metric: "backing_ratio", + triggeredValue: 0.94, + threshold: 0.98, + }, + }, + { + id: "gas_spike", + label: "Gas Price Spike", + description: "Network fees impacting operations", + input: { + severity: "medium" as SimulationSeverity, + assetCode: "", + sourceType: "network", + metric: "gas_gwei", + triggeredValue: 250, + threshold: 100, + }, + }, + { + id: "maintenance", + label: "Scheduled Maintenance", + description: "Low-priority maintenance window", + input: { + severity: "low" as SimulationSeverity, + assetCode: "", + sourceType: "maintenance", + metric: "maintenance_flag", + triggeredValue: 1, + threshold: 0, + }, + }, +] as const; + +const ADMIN_TOKEN_KEY = "bw_admin_token"; + +function loadToken(): string { + try { + return localStorage.getItem(ADMIN_TOKEN_KEY) ?? ""; + } catch { + return ""; + } +} + +const DEFAULT_INPUT: SimulationInput = { + severity: "high", + assetCode: "", + sourceType: "", + ownerAddress: "", + label: "", + triggeredValue: null, + threshold: null, + metric: "", +}; + +// ─── Main page ─────────────────────────────────────────────────────────────── + +export default function AlertSimulationSandbox() { + const [adminToken, setAdminToken] = useState(loadToken); + const [input, setInput] = useState(DEFAULT_INPUT); + const [activeTab, setActiveTab] = useState<"results" | "history">("results"); + + const { isRunning, error, currentResult, history, runSimulation, restoreFromHistory, clearHistory } = + useAlertSimulation(adminToken); + + function applyPreset(preset: (typeof PRESETS)[number]) { + setInput({ + ...DEFAULT_INPUT, + severity: preset.input.severity, + assetCode: preset.input.assetCode, + sourceType: preset.input.sourceType, + metric: preset.input.metric, + triggeredValue: preset.input.triggeredValue, + threshold: preset.input.threshold, + }); + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + void runSimulation(input); + } + + const inputCls = + "bg-stellar-card border border-stellar-border rounded px-3 py-2 text-sm text-white placeholder-stellar-text-muted focus:outline-none focus:border-stellar-blue w-full"; + + return ( +
+ {/* Header */} +
+
+

+ Alert Simulation Sandbox +

+

+ Dry-run alert rules against synthetic data before enabling in production. No alerts are dispatched. +

+
+ + + Simulation only — no real dispatches + +
+ +
+ {/* ── Left: configuration panel ──────────────────────────── */} +
+ {/* Admin token */} +
+ + { + setAdminToken(e.target.value); + try { + localStorage.setItem(ADMIN_TOKEN_KEY, e.target.value); + } catch { + // ignore + } + }} + placeholder="Enter admin API key" + className={inputCls} + autoComplete="current-password" + /> +
+ + {/* Scenario presets */} +
+

Scenario Presets

+
+ {PRESETS.map((preset) => ( + + ))} +
+
+ + {/* Input form */} +
+

Alert Parameters

+ + {/* Severity selector */} +
+ + Severity + +
+ {(["critical", "high", "medium", "low"] as SimulationSeverity[]).map((sev) => ( + + ))} +
+
+ + {/* Asset code */} +
+ + + setInput((p) => ({ ...p, assetCode: e.target.value.toUpperCase() })) + } + placeholder="e.g. USDC, ETH, WBTC" + className={inputCls} + /> +
+ + {/* Source type */} +
+ + setInput((p) => ({ ...p, sourceType: e.target.value }))} + placeholder="e.g. bridge, security, analytics" + className={inputCls} + /> +
+ + {/* Triggered value / threshold */} +
+
+ + + setInput((p) => ({ + ...p, + triggeredValue: e.target.value === "" ? null : Number(e.target.value), + })) + } + placeholder="0" + className={inputCls} + /> +
+
+ + + setInput((p) => ({ + ...p, + threshold: e.target.value === "" ? null : Number(e.target.value), + })) + } + placeholder="0" + className={inputCls} + /> +
+
+ + {/* Metric */} +
+ + setInput((p) => ({ ...p, metric: e.target.value }))} + placeholder="e.g. bridge_health, backing_ratio" + className={inputCls} + /> +
+ + {/* Owner address */} +
+ + setInput((p) => ({ ...p, ownerAddress: e.target.value }))} + placeholder="Filter rules by owner" + className={inputCls} + /> +
+ + {/* Run label */} +
+ + setInput((p) => ({ ...p, label: e.target.value }))} + placeholder="e.g. Pre-launch validation" + maxLength={120} + className={inputCls} + /> +
+ + + + {!adminToken.trim() && ( +

+ Admin API token required to run simulations +

+ )} +
+
+ + {/* ── Right: results / history ───────────────────────────── */} +
+ {/* Tab bar */} +
+ {(["results", "history"] as const).map((tab) => ( + + ))} +
+ + {/* Results tab */} + {activeTab === "results" && ( +
+ {error && ( +
+ {error} +
+ )} + + {!currentResult && !isRunning && !error && ( +
+ +

No simulation run yet

+

+ Choose a preset or configure parameters, then click Run Simulation. +

+
+ )} + + {isRunning && ( +
+ + + +

Evaluating routing rules…

+
+ )} + + {currentResult && !isRunning && ( + + )} +
+ )} + + {/* History tab */} + {activeTab === "history" && ( + { + restoreFromHistory(r); + setActiveTab("results"); + }} + onClear={clearHistory} + /> + )} +
+
+
+ ); +} + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +function SimulationResults({ result }: { result: SimulationResult }) { + const { summary, input, results, skippedInactive } = result; + const [showSkipped, setShowSkipped] = useState(false); + + return ( +
+ {/* Run metadata */} +
+ {result.simulationId} + + + {input.label && ( + <> + + "{input.label}" + + )} +
+ + {/* Summary cards */} +
+
+

+ {summary.wouldDispatch ? "FIRES" : "SILENT"} +

+

Would dispatch

+
+
+

{summary.totalMatched}

+

Rules matched

+
+
+

{summary.totalActiveRules}

+

Rules checked

+
+
+ + {/* Simulated input echo */} +
+ Severity: + + {input.severity} + + {input.assetCode && ( + <> + Asset: + {input.assetCode} + + )} + {input.sourceType && ( + <> + Source: + {input.sourceType} + + )} + {input.metric && ( + <> + Metric: + {input.metric} + + )} + {input.triggeredValue !== null && ( + <> + Value: + + {input.triggeredValue} + {input.threshold !== null && ` / ${input.threshold}`} + + + )} +
+ + {/* Effective channels */} + {summary.wouldDispatch && ( +
+

+ Effective Channels + {summary.firstMatchingRule && ( + + via "{summary.firstMatchingRule.ruleName}" + + )} +

+
+ {summary.effectiveChannels.map((ch) => ( + + {CHANNEL_LABELS[ch] ?? ch} + + ))} + {summary.effectiveFallbackChannels.length > 0 && ( + + · Fallback:{" "} + {summary.effectiveFallbackChannels + .map((ch) => CHANNEL_LABELS[ch] ?? ch) + .join(", ")} + + )} +
+ {summary.suppressionWindowSeconds > 0 && ( +

+ + Suppression window: {summary.suppressionWindowSeconds}s — rapid repeats may be suppressed in production +

+ )} +
+ )} + + {/* Per-rule breakdown */} +
+

+ Rule Evaluation ({results.length} active) +

+
+ {results.map((r) => ( +
+
+
+ {r.matched ? ( + + + + ) : ( + + + + )} + + {r.ruleName} + +
+
+ P{r.priorityOrder} + {r.matched && ( + + MATCH + + )} +
+
+ +
    + {r.reasons.map((reason, i) => ( +
  • + + {reason} +
  • + ))} +
+ + {r.matched && r.channels.length > 0 && ( +
+ {r.channels.map((ch) => ( + + {CHANNEL_LABELS[ch] ?? ch} + + ))} + {r.suppressionWindowSeconds > 0 && ( + + {r.suppressionWindowSeconds}s suppression + + )} +
+ )} +
+ ))} + + {results.length === 0 && ( +
+ No active routing rules found for this owner. +
+ )} +
+ + {skippedInactive.length > 0 && ( +
+ + {showSkipped && ( +
    + {skippedInactive.map((r) => ( +
  • + P{r.priorityOrder} — {r.ruleName} +
  • + ))} +
+ )} +
+ )} +
+
+ ); +} + +function SimulationHistory({ + history, + onRestore, + onClear, +}: { + history: SimulationResult[]; + onRestore: (r: SimulationResult) => void; + onClear: () => void; +}) { + if (history.length === 0) { + return ( +
+

No simulation runs recorded yet.

+

Runs are saved locally in your browser.

+
+ ); + } + + return ( +
+
+

+ {history.length} run{history.length !== 1 ? "s" : ""} — stored locally +

+ +
+ +
+ {history.map((run) => ( + + ))} +
+
+ ); +}