fix(notifications): scope routing rules to nodes via node_id column (#775)

Adds a nullable node_id column to notification_routes (null = any
node, integer = fire only when the alert originates from that specific
node). This fixes a multi-node fleet defect where a route scoped to
"my-app" would fire on every node that hosts a stack with that name.

Backend changes:
- DatabaseService: idempotent migration adds node_id INTEGER NULL and
  a composite index on (node_id, enabled, priority); the two statements
  are in separate try-catch blocks so the index is always created even
  when the column was added in an earlier run
- NotificationService: route matcher now pre-filters by node_id before
  checking stack_patterns (== null matches any node)
- notifications route: POST/PUT accept optional node_id, validated to
  be null or the local node's ID; NodeRegistry guards against
  cross-node misroutes

Frontend changes:
- NotificationRoutingSection: node scope Select field uses useNodes()
  from NodeContext (no extra API call) to populate the local node option
- Route cards show a node badge when node_id is set

Tests: 3 new tests covering node-match, node-mismatch, and null-scope;
all 75 files (1413 tests) passing.
This commit is contained in:
Anso
2026-04-25 13:56:48 -04:00
committed by GitHub
parent 44dba59cab
commit fcbdd59ec2
5 changed files with 124 additions and 5 deletions
+27 -2
View File
@@ -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<void> => {
@@ -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<string, unknown> = { 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();