diff --git a/backend/src/__tests__/notification-routing.test.ts b/backend/src/__tests__/notification-routing.test.ts index 86d6406d..e2ca8294 100644 --- a/backend/src/__tests__/notification-routing.test.ts +++ b/backend/src/__tests__/notification-routing.test.ts @@ -58,6 +58,7 @@ function makeRoute(overrides: Record = {}) { return { id: 1, name: 'Prod Discord', + node_id: null as number | null, stack_patterns: ['my-app'], channel_type: 'discord' as const, channel_url: 'https://discord.com/api/webhooks/123/abc', @@ -312,4 +313,48 @@ describe('NotificationService - routing logic', () => { expect(mockUpdateNotificationDispatchError).not.toHaveBeenCalled(); }); + + it('fires a node-scoped route when node_id matches the local node', async () => { + // getDefaultNodeId returns 1 (mocked above) + mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute({ node_id: 1 })]); + mockGetEnabledAgents.mockReturnValue([]); + + await svc.dispatchAlert('info', 'Test', 'my-app'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://discord.com/api/webhooks/123/abc', + expect.objectContaining({ method: 'POST' }) + ); + }); + + it('skips a node-scoped route when node_id does not match the local node', async () => { + // getDefaultNodeId returns 1; route is scoped to node 99 + mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute({ node_id: 99 })]); + mockGetEnabledAgents.mockReturnValue([makeAgent()]); + + await svc.dispatchAlert('info', 'Test', 'my-app'); + + // Route should be skipped; falls back to global agent + expect(mockFetch).not.toHaveBeenCalledWith( + 'https://discord.com/api/webhooks/123/abc', + expect.anything() + ); + expect(mockFetch).toHaveBeenCalledWith( + 'https://hooks.slack.com/services/global', + expect.objectContaining({ method: 'POST' }) + ); + }); + + it('fires a null-scoped route regardless of which node emits the alert', async () => { + // node_id=null means "any node" + mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute({ node_id: null })]); + mockGetEnabledAgents.mockReturnValue([]); + + await svc.dispatchAlert('warning', 'Global alert', 'my-app'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://discord.com/api/webhooks/123/abc', + expect.objectContaining({ method: 'POST' }) + ); + }); }); diff --git a/backend/src/routes/notifications.ts b/backend/src/routes/notifications.ts index 61565fdc..cbcc1fe4 100644 --- a/backend/src/routes/notifications.ts +++ b/backend/src/routes/notifications.ts @@ -1,6 +1,7 @@ import { Router, type Request, type Response } from 'express'; import { DatabaseService } from '../services/DatabaseService'; import { NotificationService } from '../services/NotificationService'; +import { NodeRegistry } from '../services/NodeRegistry'; import { authMiddleware } from '../middleware/auth'; import { requireAdmin, requireAdmiral } from '../middleware/tierGates'; import { @@ -20,6 +21,20 @@ function parseRouteId(req: Request, res: Response): number | null { return id; } +function validateNodeId(nodeId: unknown, res: Response): number | null | false { + if (nodeId === undefined || nodeId === null) return null; + if (typeof nodeId !== 'number' || !Number.isInteger(nodeId)) { + res.status(400).json({ error: 'node_id must be an integer or null' }); + return false; + } + const localNodeId = NodeRegistry.getInstance().getDefaultNodeId(); + if (nodeId !== localNodeId) { + res.status(400).json({ error: 'node_id must match the local node or be null' }); + return false; + } + return nodeId; +} + export const notificationsRouter = Router(); notificationsRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise => { @@ -100,7 +115,7 @@ notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Res if (!requireAdmin(req, res)) return; if (!requireAdmiral(req, res)) return; try { - const { name, stack_patterns, channel_type, channel_url, priority, enabled } = req.body; + const { name, node_id: rawNodeId, stack_patterns, channel_type, channel_url, priority, enabled } = req.body; if (!name || typeof name !== 'string' || !name.trim()) { res.status(400).json({ error: 'Name is required' }); @@ -110,6 +125,8 @@ notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Res res.status(400).json({ error: 'Name must be 100 characters or fewer' }); return; } + const nodeIdResult = validateNodeId(rawNodeId, res); + if (nodeIdResult === false) return; if (!Array.isArray(stack_patterns) || stack_patterns.length === 0 || stack_patterns.some((p: unknown) => typeof p !== 'string')) { res.status(400).json({ error: 'stack_patterns must be a non-empty array of stack names' }); return; @@ -133,6 +150,7 @@ notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Res const now = Date.now(); const route = DatabaseService.getInstance().createNotificationRoute({ name: name.trim(), + node_id: nodeIdResult, stack_patterns: cleanedPatterns, channel_type, channel_url: channel_url.trim(), @@ -160,7 +178,7 @@ notificationRoutesRouter.put('/:id', authMiddleware, async (req: Request, res: R const existing = DatabaseService.getInstance().getNotificationRoute(id); if (!existing) { res.status(404).json({ error: 'Route not found' }); return; } - const { name, stack_patterns, channel_type, channel_url, priority, enabled } = req.body; + const { name, node_id: rawNodeId, stack_patterns, channel_type, channel_url, priority, enabled } = req.body; if (name !== undefined && (typeof name !== 'string' || !name.trim())) { res.status(400).json({ error: 'Name must be a non-empty string' }); @@ -170,6 +188,12 @@ notificationRoutesRouter.put('/:id', authMiddleware, async (req: Request, res: R res.status(400).json({ error: 'Name must be 100 characters or fewer' }); return; } + let validatedNodeId: number | null | undefined; + if ('node_id' in req.body) { + const result = validateNodeId(rawNodeId, res); + if (result === false) return; + validatedNodeId = result; + } let cleanedPatterns: string[] | undefined; if (stack_patterns !== undefined) { if (!Array.isArray(stack_patterns) || stack_patterns.length === 0 || stack_patterns.some((p: unknown) => typeof p !== 'string')) { @@ -201,6 +225,7 @@ notificationRoutesRouter.put('/:id', authMiddleware, async (req: Request, res: R const updates: Record = { updated_at: Date.now() }; if (name !== undefined) updates.name = name.trim(); + if (validatedNodeId !== undefined) updates.node_id = validatedNodeId; if (cleanedPatterns !== undefined) updates.stack_patterns = cleanedPatterns; if (channel_type !== undefined) updates.channel_type = channel_type; if (channel_url !== undefined) updates.channel_url = channel_url.trim(); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 2afbc67b..744e3fff 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -307,6 +307,7 @@ export interface Registry { export interface NotificationRoute { id: number; name: string; + node_id: number | null; stack_patterns: string[]; channel_type: 'discord' | 'slack' | 'webhook'; channel_url: string; @@ -485,6 +486,7 @@ export class DatabaseService { this.migrateRegistries(); this.migrateRoleAssignments(); this.migrateNotificationRoutes(); + this.migrateNotificationRoutesNodeId(); this.migrateNotificationHistoryContext(); this.migrateScanPolicyFleetColumns(); this.migrateSecretMisconfigColumns(); @@ -1130,6 +1132,15 @@ export class DatabaseService { try { this.db.prepare('ALTER TABLE notification_history ADD COLUMN dispatch_error TEXT').run(); } catch { /* already exists */ } } + private migrateNotificationRoutesNodeId(): void { + try { + this.db.prepare('ALTER TABLE notification_routes ADD COLUMN node_id INTEGER NULL').run(); + } catch { + // column already present + } + this.db.prepare('CREATE INDEX IF NOT EXISTS idx_notification_routes_node_priority ON notification_routes(node_id, enabled, priority)').run(); + } + private migrateNotificationHistoryContext(): void { const tryAddColumn = (col: string, def: string) => { try { @@ -1247,6 +1258,7 @@ export class DatabaseService { return { id: row.id as number, name: row.name as string, + node_id: row.node_id != null ? (row.node_id as number) : null, stack_patterns: JSON.parse(row.stack_patterns as string) as string[], channel_type: row.channel_type as 'discord' | 'slack' | 'webhook', channel_url: row.channel_url as string, @@ -1276,9 +1288,10 @@ export class DatabaseService { public createNotificationRoute(route: Omit): NotificationRoute { const result = this.db.prepare( - 'INSERT INTO notification_routes (name, stack_patterns, channel_type, channel_url, priority, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + 'INSERT INTO notification_routes (name, node_id, stack_patterns, channel_type, channel_url, priority, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' ).run( route.name, + route.node_id ?? null, JSON.stringify(route.stack_patterns), route.channel_type, route.channel_url, @@ -1295,6 +1308,7 @@ export class DatabaseService { const values: unknown[] = []; if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); } + if ('node_id' in updates) { fields.push('node_id = ?'); values.push(updates.node_id ?? null); } if (updates.stack_patterns !== undefined) { fields.push('stack_patterns = ?'); values.push(JSON.stringify(updates.stack_patterns)); } if (updates.channel_type !== undefined) { fields.push('channel_type = ?'); values.push(updates.channel_type); } if (updates.channel_url !== undefined) { fields.push('channel_url = ?'); values.push(updates.channel_url); } diff --git a/backend/src/services/NotificationService.ts b/backend/src/services/NotificationService.ts index 907bc544..d1dff71b 100644 --- a/backend/src/services/NotificationService.ts +++ b/backend/src/services/NotificationService.ts @@ -122,7 +122,10 @@ export class NotificationService { if (stackName !== undefined) { const routes = this.dbService.getEnabledNotificationRoutes(); - const matched = routes.filter(r => r.stack_patterns.includes(stackName)); + const matched = routes.filter(r => + (r.node_id == null || r.node_id === localNodeId) && + r.stack_patterns.includes(stackName) + ); if (matched.length > 0) { if (isDebugEnabled()) console.log(`[Notify:diag] Matched ${matched.length} route(s) for stack "${stackName}"`); await Promise.allSettled( diff --git a/frontend/src/components/settings/NotificationRoutingSection.tsx b/frontend/src/components/settings/NotificationRoutingSection.tsx index 423be659..e9e8531b 100644 --- a/frontend/src/components/settings/NotificationRoutingSection.tsx +++ b/frontend/src/components/settings/NotificationRoutingSection.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -8,6 +8,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Combobox } from '@/components/ui/combobox'; import type { ComboboxOption } from '@/components/ui/combobox'; import { Tabs, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { springs } from '@/lib/motion'; import { Dialog, @@ -28,6 +29,7 @@ import { } from '@/components/ui/alert-dialog'; import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; +import { useNodes } from '@/context/NodeContext'; import { AdmiralGate } from '@/components/AdmiralGate'; import { CapabilityGate } from '@/components/CapabilityGate'; import { Plus, Trash2, Pencil, RefreshCw, Zap, X, Route } from 'lucide-react'; @@ -35,6 +37,7 @@ import { Plus, Trash2, Pencil, RefreshCw, Zap, X, Route } from 'lucide-react'; interface NotificationRoute { id: number; name: string; + node_id: number | null; stack_patterns: string[]; channel_type: 'discord' | 'slack' | 'webhook'; channel_url: string; @@ -57,6 +60,8 @@ const CHANNEL_PLACEHOLDERS: Record = { }; export function NotificationRoutingSection() { + const { nodes } = useNodes(); + const localNode = useMemo(() => nodes.find(n => n.type === 'local') ?? null, [nodes]); const [routes, setRoutes] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -67,6 +72,7 @@ export function NotificationRoutingSection() { // Form state const [formName, setFormName] = useState(''); + const [formNodeId, setFormNodeId] = useState(null); const [formStacks, setFormStacks] = useState([]); const [formChannelType, setFormChannelType] = useState<'discord' | 'slack' | 'webhook'>('discord'); const [formChannelUrl, setFormChannelUrl] = useState(''); @@ -105,6 +111,7 @@ export function NotificationRoutingSection() { const resetForm = () => { setFormName(''); + setFormNodeId(null); setFormStacks([]); setFormChannelType('discord'); setFormChannelUrl(''); @@ -117,6 +124,7 @@ export function NotificationRoutingSection() { const startEdit = (route: NotificationRoute) => { setEditingId(route.id); setFormName(route.name); + setFormNodeId(route.node_id); setFormStacks([...route.stack_patterns]); setFormChannelType(route.channel_type); setFormChannelUrl(route.channel_url); @@ -137,6 +145,7 @@ export function NotificationRoutingSection() { try { const body = { name: formName.trim(), + node_id: formNodeId, stack_patterns: formStacks, channel_type: formChannelType, channel_url: formChannelUrl.trim(), @@ -256,6 +265,24 @@ export function NotificationRoutingSection() { /> +
+ + +
+
{CHANNEL_LABELS[route.channel_type]} + {route.node_id !== null && ( + + {route.node_id === localNode?.id ? localNode?.name : `node:${route.node_id}`} + + )} {!route.enabled && ( Disabled