From 66b84932e0dd4cc5a89c632f599933289a02c195 Mon Sep 17 00:00:00 2001 From: Anso Date: Thu, 21 May 2026 20:57:34 -0400 Subject: [PATCH] feat(notifications): move Notification Routing to Skipper tier (#1145) * feat(notifications): move Notification Routing to Skipper tier Notification routing is automation (route alerts to channels by rules), not enterprise compliance. Aligning the gate with Skipper makes the tier boundary read consistently with the rest of the automation surface (webhooks, auto-update, auto-heal, scheduled tasks). Backend: requireAdmiral -> requirePaid on the five /api/notification-routes endpoints. Dashboard configuration-status now exposes the routing-rules row to any paid tier. Frontend: settings registry tier flipped to skipper; the Admiral wrapper around NotificationRoutingSection is removed (the inner CapabilityGate stays, preserving forward-compat with older remote nodes). Tests: added a tier-enforcement describe block covering Skipper (200) and Community (403 PAID_REQUIRED on all five endpoints). Docs: refreshed alerts-notifications, licensing, overview, dashboard, troubleshooting, and reference/settings; cleaned one fence-spec line per Directive 31. * fix(notifications): address audit findings on tier-move PR Docs: rewrite three lines that survived the initial sweep. The dashboard "you do not see a locked placeholder" clause and the settings.mdx "hidden on Community and Skipper" phrase were Directive 31 fence-spec. The alerts-notifications troubleshooting note still said "an Admiral routing rule" and contradicted the tier move. Tests: the Community-negative cases on POST/PUT/DELETE/POST :id/test could not distinguish requirePaid from a stray requireAdmiral, because Community fails on the tier check before variant is read. Adding Skipper-positive coverage per endpoint locks the gate identity in. Replace the leaky mockReturnValueOnce with a per-test mockReturnValue plus an afterEach restore so spies cannot bleed across tests. --- .../__tests__/notification-routes-api.test.ts | 102 +++++++++++++++++- backend/src/routes/dashboard.ts | 6 +- backend/src/routes/notifications.ts | 12 +-- docs/features/alerts-notifications.mdx | 8 +- docs/features/dashboard.mdx | 4 +- docs/features/licensing.mdx | 2 +- docs/features/overview.mdx | 2 +- docs/operations/troubleshooting.mdx | 2 +- docs/reference/settings.mdx | 4 +- .../dashboard/useConfigurationStatus.ts | 2 +- .../settings/NotificationRoutingSection.tsx | 7 +- frontend/src/components/settings/registry.ts | 2 +- 12 files changed, 122 insertions(+), 31 deletions(-) diff --git a/backend/src/__tests__/notification-routes-api.test.ts b/backend/src/__tests__/notification-routes-api.test.ts index 6a8daa3b..9e0ac74e 100644 --- a/backend/src/__tests__/notification-routes-api.test.ts +++ b/backend/src/__tests__/notification-routes-api.test.ts @@ -2,7 +2,7 @@ * Integration tests for Notification Routes CRUD endpoints, * auth enforcement, input validation, and test dispatch. */ -import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; import request from 'supertest'; import bcrypt from 'bcrypt'; import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; @@ -10,6 +10,7 @@ import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTes let tmpDir: string; let app: import('express').Express; let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let licenseService: import('../services/LicenseService').LicenseService; let authCookie: string; let viewerCookie: string; @@ -19,9 +20,10 @@ beforeAll(async () => { // Mock LicenseService so Admiral-gated routes are accessible const { LicenseService } = await import('../services/LicenseService'); - vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); - vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral'); - vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null }); + licenseService = LicenseService.getInstance(); + vi.spyOn(licenseService, 'getTier').mockReturnValue('paid'); + vi.spyOn(licenseService, 'getVariant').mockReturnValue('admiral'); + vi.spyOn(licenseService, 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null }); ({ app } = await import('../index')); authCookie = await loginAsTestAdmin(app); @@ -88,6 +90,98 @@ describe('Notification Routes - auth enforcement', () => { }); }); +// --- Tier enforcement (Skipper or Admiral) --- +// +// Skipper-positive tests exist per endpoint so that a future regression +// reverting any single handler to `requireAdmiral` is caught: with the +// default mock returning `admiral`, a stray `requireAdmiral` would still +// pass the Community-negative tests below (Community fails on tier +// before variant is checked), so only Skipper-positive coverage proves +// the gate is `requirePaid`. `afterEach` restores the suite defaults so +// per-test mock overrides cannot leak across tests. + +describe('Notification Routes - tier enforcement', () => { + afterEach(() => { + vi.spyOn(licenseService, 'getTier').mockReturnValue('paid'); + vi.spyOn(licenseService, 'getVariant').mockReturnValue('admiral'); + }); + + it('GET /api/notification-routes returns 200 when the variant is Skipper', async () => { + vi.spyOn(licenseService, 'getVariant').mockReturnValue('skipper'); + const res = await request(app).get('/api/notification-routes').set('Cookie', authCookie); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); + + it('POST /api/notification-routes returns 201 when the variant is Skipper', async () => { + vi.spyOn(licenseService, 'getVariant').mockReturnValue('skipper'); + const res = await request(app) + .post('/api/notification-routes') + .set('Cookie', authCookie) + .send({ name: 'skipper-positive', stack_patterns: ['app'], channel_type: 'discord', channel_url: 'https://discord.com/api/webhooks/123/abc' }); + expect(res.status).toBe(201); + if (typeof res.body?.id === 'number') { + DatabaseService.getInstance().deleteNotificationRoute(res.body.id); + } + }); + + it('PUT /api/notification-routes/:id returns 404 (gate passed) when the variant is Skipper', async () => { + vi.spyOn(licenseService, 'getVariant').mockReturnValue('skipper'); + const res = await request(app).put('/api/notification-routes/99999').set('Cookie', authCookie).send({ name: 'x' }); + expect(res.status).toBe(404); + }); + + it('DELETE /api/notification-routes/:id returns 404 (gate passed) when the variant is Skipper', async () => { + vi.spyOn(licenseService, 'getVariant').mockReturnValue('skipper'); + const res = await request(app).delete('/api/notification-routes/99999').set('Cookie', authCookie); + expect(res.status).toBe(404); + }); + + it('POST /api/notification-routes/:id/test returns 404 (gate passed) when the variant is Skipper', async () => { + vi.spyOn(licenseService, 'getVariant').mockReturnValue('skipper'); + const res = await request(app).post('/api/notification-routes/99999/test').set('Cookie', authCookie); + expect(res.status).toBe(404); + }); + + it('GET /api/notification-routes returns 403 PAID_REQUIRED on Community', async () => { + vi.spyOn(licenseService, 'getTier').mockReturnValue('community'); + const res = await request(app).get('/api/notification-routes').set('Cookie', authCookie); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PAID_REQUIRED'); + }); + + it('POST /api/notification-routes returns 403 PAID_REQUIRED on Community', async () => { + vi.spyOn(licenseService, 'getTier').mockReturnValue('community'); + const res = await request(app) + .post('/api/notification-routes') + .set('Cookie', authCookie) + .send({ name: 'x', stack_patterns: ['app'], channel_type: 'discord', channel_url: 'https://discord.com/api/webhooks/123/abc' }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PAID_REQUIRED'); + }); + + it('PUT /api/notification-routes/:id returns 403 PAID_REQUIRED on Community', async () => { + vi.spyOn(licenseService, 'getTier').mockReturnValue('community'); + const res = await request(app).put('/api/notification-routes/1').set('Cookie', authCookie).send({ name: 'x' }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PAID_REQUIRED'); + }); + + it('DELETE /api/notification-routes/:id returns 403 PAID_REQUIRED on Community', async () => { + vi.spyOn(licenseService, 'getTier').mockReturnValue('community'); + const res = await request(app).delete('/api/notification-routes/1').set('Cookie', authCookie); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PAID_REQUIRED'); + }); + + it('POST /api/notification-routes/:id/test returns 403 PAID_REQUIRED on Community', async () => { + vi.spyOn(licenseService, 'getTier').mockReturnValue('community'); + const res = await request(app).post('/api/notification-routes/1/test').set('Cookie', authCookie); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PAID_REQUIRED'); + }); +}); + // --- Agents Auth (now requires authMiddleware) --- describe('Agents endpoints - auth enforcement', () => { diff --git a/backend/src/routes/dashboard.ts b/backend/src/routes/dashboard.ts index b3810f50..04ff16f2 100644 --- a/backend/src/routes/dashboard.ts +++ b/backend/src/routes/dashboard.ts @@ -17,7 +17,7 @@ export interface ConfigurationStatus { notifications: { agents: { discord: AgentStatus; slack: AgentStatus; webhook: AgentStatus }; alertRules: number; - routingRules: { count: number; enabledCount: number; locked: boolean; requiredTier: 'admiral' }; + routingRules: { count: number; enabledCount: number; locked: boolean; requiredTier: 'skipper' }; }; automation: { autoHeal: { total: number; enabled: number }; @@ -101,8 +101,8 @@ export function buildLocalConfigurationStatus( routingRules: { count: notifRoutes.length, enabledCount: notifRoutes.filter(r => r.enabled).length, - locked: !isAdmiral, - requiredTier: 'admiral', + locked: !isPaid, + requiredTier: 'skipper', }, }, automation: { diff --git a/backend/src/routes/notifications.ts b/backend/src/routes/notifications.ts index f6543688..ba5b277f 100644 --- a/backend/src/routes/notifications.ts +++ b/backend/src/routes/notifications.ts @@ -4,7 +4,7 @@ import { NotificationService, ALL_NOTIFICATION_CATEGORIES } from '../services/No import type { NotificationCategory } from '../services/NotificationService'; import { NodeRegistry } from '../services/NodeRegistry'; import { authMiddleware } from '../middleware/auth'; -import { requireAdmin, requireAdmiral } from '../middleware/tierGates'; +import { requireAdmin, requirePaid } from '../middleware/tierGates'; import { NOTIFICATION_CHANNEL_TYPES, validateHttpsUrl, @@ -114,7 +114,7 @@ export const notificationRoutesRouter = Router(); notificationRoutesRouter.get('/', authMiddleware, (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; - if (!requireAdmiral(req, res)) return; + if (!requirePaid(req, res)) return; try { const routes = DatabaseService.getInstance().getNotificationRoutes(); res.json(routes); @@ -126,7 +126,7 @@ notificationRoutesRouter.get('/', authMiddleware, (req: Request, res: Response): notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; - if (!requireAdmiral(req, res)) return; + if (!requirePaid(req, res)) return; try { const { name, node_id: rawNodeId, stack_patterns, label_ids, categories, channel_type, channel_url, priority, enabled } = req.body; @@ -183,7 +183,7 @@ notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Res notificationRoutesRouter.put('/:id', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; - if (!requireAdmiral(req, res)) return; + if (!requirePaid(req, res)) return; try { const id = parseIntParam(req, res, 'id', 'route ID'); if (id === null) return; @@ -259,7 +259,7 @@ notificationRoutesRouter.put('/:id', authMiddleware, async (req: Request, res: R notificationRoutesRouter.delete('/:id', authMiddleware, (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; - if (!requireAdmiral(req, res)) return; + if (!requirePaid(req, res)) return; try { const id = parseIntParam(req, res, 'id', 'route ID'); if (id === null) return; @@ -276,7 +276,7 @@ notificationRoutesRouter.delete('/:id', authMiddleware, (req: Request, res: Resp notificationRoutesRouter.post('/:id/test', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; - if (!requireAdmiral(req, res)) return; + if (!requirePaid(req, res)) return; try { const id = parseIntParam(req, res, 'id', 'route ID'); if (id === null) return; diff --git a/docs/features/alerts-notifications.mdx b/docs/features/alerts-notifications.mdx index a9b4c95d..31d4416f 100644 --- a/docs/features/alerts-notifications.mdx +++ b/docs/features/alerts-notifications.mdx @@ -1,9 +1,9 @@ --- title: Alerts & Notifications -description: Threshold and event alerts for your fleet, dispatched to Discord, Slack, or any webhook, with per-stack rules and Admiral routing. +description: Threshold and event alerts for your fleet, dispatched to Discord, Slack, or any webhook, with per-stack rules and Skipper routing. --- -Sencho watches each node it manages for container crashes, host pressure, scheduled-task results, and update availability, then surfaces every signal in two places: the in-app notification bell at the top of the shell and one of three external channels you configure. This page covers everything from configuring channels to writing per-stack threshold rules, routing alerts to dedicated channels with Admiral routing rules, and tuning retention. +Sencho watches each node it manages for container crashes, host pressure, scheduled-task results, and update availability, then surfaces every signal in two places: the in-app notification bell at the top of the shell and one of three external channels you configure. This page covers everything from configuring channels to writing per-stack threshold rules, routing alerts to dedicated channels with Skipper routing rules, and tuning retention. Settings 路 Notifications panel showing the Discord, Slack, and Webhook tabs with the masthead breadcrumb, the CHANNELS 3/3 stat, the active Discord tab with its Enabled toggle on, the Webhook URL input, and the Test and Save actions. @@ -51,7 +51,7 @@ Each dispatch is a single-shot HTTP POST with a 10-second `AbortSignal.timeout`. ## Notification Routing - Notification Routing requires a **Sencho Admiral** license. Admin role is required to create, edit, or delete routes. + Notification Routing requires a **Sencho Skipper or Admiral** license. Admin role is required to create, edit, or delete routes. Routing lets you direct alerts that match specific criteria to dedicated channels. Production crashes can land in `#prod-incidents` on Slack while staging notifications go to a less urgent Discord channel, all without juggling per-channel webhook URLs across teams. @@ -392,7 +392,7 @@ Switching the active node tears down per-stack rule editors and reloads channel - Check three things in order. First, the channel toggle in **Settings 路 Notifications** must be on; the kicker on each tab reads `enabled` or `off`. Second, the URL must use HTTPS; the form rejects plain `http://` outright. Third, an Admiral routing rule with empty `Stacks`, `Labels`, and `Categories` matchers will intercept every alert and skip the global channels. Use the per-channel **Test** button to issue a one-shot dispatch and watch your endpoint for the literal message `馃攲 Test Notification from Sencho!` Sencho records the failure reason in `notification_history.dispatch_error` when delivery throws, so a row that appears in the bell with no follow-up at the endpoint usually means a 4xx or timeout at the receiver. + Check three things in order. First, the channel toggle in **Settings 路 Notifications** must be on; the kicker on each tab reads `enabled` or `off`. Second, the URL must use HTTPS; the form rejects plain `http://` outright. Third, a routing rule with empty `Stacks`, `Labels`, and `Categories` matchers will intercept every alert and skip the global channels. Use the per-channel **Test** button to issue a one-shot dispatch and watch your endpoint for the literal message `馃攲 Test Notification from Sencho!` Sencho records the failure reason in `notification_history.dispatch_error` when delivery throws, so a row that appears in the bell with no follow-up at the endpoint usually means a 4xx or timeout at the receiver. Three causes account for almost every case. First, the rule's **Duration** has not elapsed yet: the breach must persist for the full duration before the rule fires. Second, the rule is still in cooldown after a previous fire. Third, the panel's banner is not green: a remote-node banner means the rule was saved on a remote whose channels you may not have configured, and an amber `No notification channels configured` banner means the rule evaluates fine but Sencho has nowhere to send the alert. The evaluator runs on a 30-second tick, so expect up to 30 seconds of latency between the breach starting and the timer engaging. diff --git a/docs/features/dashboard.mdx b/docs/features/dashboard.mdx index 7cd422ad..6e80eb1e 100644 --- a/docs/features/dashboard.mdx +++ b/docs/features/dashboard.mdx @@ -89,7 +89,7 @@ The card is divided into four sections. |-----|---------------| | **Notification agents** | The list of enabled delivery agents from `Discord`, `Slack`, `Webhook`, joined by commas; reads `None` when no agent is enabled | | **Alert rules** | Total per-stack alert rules in effect, formatted ` rules` | -| **Notification routing** (Admiral) | Number of enabled routing rules that direct categories to specific agents, formatted ` routes` | +| **Notification routing** | Number of enabled routing rules that direct categories to specific agents, formatted ` routes` | ### Automation @@ -118,7 +118,7 @@ The Automation block only renders on Skipper or Admiral. | **Alert thresholds** | The current host thresholds, formatted `CPU x% 路 RAM y% 路 Disk z%` | | **Crash detection** | `On` when global container-crash notifications are enabled, `Off` otherwise | -Click any row to jump directly to the settings section that manages it. Rows that require a higher tier than the active license are not rendered at all; you do not see a locked placeholder. The section headers (Notifications, Automation, Security, Backups & Thresholds) only render when at least one of their rows is visible. +Click any row to jump directly to the settings section that manages it. Rows that require a higher tier than the active license are not rendered. The section headers (Notifications, Automation, Security, Backups & Thresholds) only render when at least one of their rows is visible. The data refreshes automatically every 60 seconds and immediately on any container start/stop/restart event broadcast over the live notification stream, so the card stays in lockstep with what the rest of the UI shows. diff --git a/docs/features/licensing.mdx b/docs/features/licensing.mdx index fb6c9654..a6734574 100644 --- a/docs/features/licensing.mdx +++ b/docs/features/licensing.mdx @@ -41,6 +41,7 @@ For larger deployments, an **Enterprise** tier is available with custom pricing, - Fleet View with search, sort, filter, and node-card drill-down - Webhooks (incoming, to trigger deploys from CI/CD) +- Notification routing (per-stack and per-category rules to Discord, Slack, or any webhook) - Atomic deployments with rollback - Auto-update policies for stack images - Auto-heal policies @@ -61,7 +62,6 @@ For larger deployments, an **Enterprise** tier is available with custom pricing, - Host Console (a browser-based terminal on the Sencho host) - API tokens for CI/CD scripts - Private and custom registry credentials -- Notification routing - Sencho Mesh (cross-node container networking) - Sencho Cloud Backup - Auto-update of the managed Trivy binary diff --git a/docs/features/overview.mdx b/docs/features/overview.mdx index b86464f0..550d0cc5 100644 --- a/docs/features/overview.mdx +++ b/docs/features/overview.mdx @@ -83,7 +83,7 @@ Configure threshold-based alerts (CPU, memory, network, restart count) per stack ### Notification routing -Route alerts to specific channels with per-stack routing rules. Send production alerts to a critical Slack channel while routing dev stack alerts to a less urgent Discord channel. Admiral only. [Learn more 鈫抅(/features/alerts-notifications#notification-routing) +Route alerts to specific channels with per-stack routing rules. Send production alerts to a critical Slack channel while routing dev stack alerts to a less urgent Discord channel. [Learn more 鈫抅(/features/alerts-notifications#notification-routing) ### Audit log diff --git a/docs/operations/troubleshooting.mdx b/docs/operations/troubleshooting.mdx index ade20a7e..cec98c7e 100644 --- a/docs/operations/troubleshooting.mdx +++ b/docs/operations/troubleshooting.mdx @@ -480,7 +480,7 @@ docker compose pull && docker compose up -d 1. **Stack name match**: The stack name in the routing rule must match exactly. Stack names are case-sensitive and correspond to the directory name in your compose folder. 2. **Rule is enabled**: Check that the route's toggle is turned on in **Profile > Settings > Routing**. -3. **License tier**: Notification Routing requires an Admiral license. Skipper and Community users can only use global channels. +3. **License tier**: Notification Routing requires a Skipper or Admiral license. --- diff --git a/docs/reference/settings.mdx b/docs/reference/settings.mdx index 5bc02a1c..8c056ba9 100644 --- a/docs/reference/settings.mdx +++ b/docs/reference/settings.mdx @@ -322,7 +322,7 @@ At least one agent must be enabled for stack alerts to deliver notifications. Se ## Routing - Notification Routing requires a Sencho Admiral license. + Notification Routing requires a Sencho Skipper or Admiral license. **Scope:** Global, admin-only @@ -445,7 +445,7 @@ Advanced settings for debug diagnostics and data retention. Most operators can l |---------|---------|-----|-------------| | **Container metrics** | 24 hrs | 8,760 (1 year) | How long to keep per-container CPU, RAM, and network history for dashboard charts. | | **Notification log** | 30 days | 365 | How long to keep alert and notification history. | -| **Audit log** | 90 days | 365 | How long to keep audit trail entries. Admiral only; the row is hidden on Community and Skipper. | +| **Audit log** | 90 days | 365 | How long to keep audit trail entries. Requires Admiral. | Click **Save settings** to apply. Retention rows are global; the **Developer mode** toggle is per-node. diff --git a/frontend/src/components/dashboard/useConfigurationStatus.ts b/frontend/src/components/dashboard/useConfigurationStatus.ts index f0d87926..d051077f 100644 --- a/frontend/src/components/dashboard/useConfigurationStatus.ts +++ b/frontend/src/components/dashboard/useConfigurationStatus.ts @@ -14,7 +14,7 @@ export interface ConfigurationStatus { notifications: { agents: { discord: AgentStatus; slack: AgentStatus; webhook: AgentStatus }; alertRules: number; - routingRules: { count: number; enabledCount: number; locked: boolean; requiredTier: 'admiral' }; + routingRules: { count: number; enabledCount: number; locked: boolean; requiredTier: 'skipper' }; }; automation: { autoHeal: { total: number; enabled: number }; diff --git a/frontend/src/components/settings/NotificationRoutingSection.tsx b/frontend/src/components/settings/NotificationRoutingSection.tsx index 33843e4c..87e6f423 100644 --- a/frontend/src/components/settings/NotificationRoutingSection.tsx +++ b/frontend/src/components/settings/NotificationRoutingSection.tsx @@ -14,7 +14,6 @@ import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/comp 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 type { NotificationCategory } from '@/components/dashboard/types'; import type { Label as StackLabel } from '@/components/label-types'; @@ -298,8 +297,7 @@ export function NotificationRoutingSection() { ); return ( - - +
{ resetForm(); setShowForm(true); }}> @@ -603,7 +601,6 @@ export function NotificationRoutingSection() {

- - + ); } diff --git a/frontend/src/components/settings/registry.ts b/frontend/src/components/settings/registry.ts index 8c2fe6cc..502de5e2 100644 --- a/frontend/src/components/settings/registry.ts +++ b/frontend/src/components/settings/registry.ts @@ -150,7 +150,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [ label: 'Routing', description: 'Rules that steer alerts to the right channel based on severity or label.', keywords: ['rules', 'routing', 'channels', 'severity', 'labels'], - tier: 'admiral', + tier: 'skipper', scope: 'global', adminOnly: true, hiddenOnRemote: true,