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,