diff --git a/backend/src/__tests__/agents-routes.test.ts b/backend/src/__tests__/agents-routes.test.ts index 51df399c..751c76a1 100644 --- a/backend/src/__tests__/agents-routes.test.ts +++ b/backend/src/__tests__/agents-routes.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vites import request from 'supertest'; import bcrypt from 'bcrypt'; import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; +import { PAYLOAD_TEMPLATE_MAX_LENGTH } from '../helpers/notificationPayloadTemplate'; let tmpDir: string; let app: import('express').Express; @@ -243,3 +244,150 @@ describe('Apprise agents - redaction and preserve-on-write', () => { expect(JSON.stringify(res.body)).not.toContain('key-secret-value'); }); }); + +describe('payload templates', () => { + it('stores and returns a valid payload template', async () => { + const template = '{"title": "{{level}}", "body": "{{message}}"}'; + const res = await request(app).post('/api/agents').set('Cookie', adminCookie).send({ + type: 'discord', + url: 'https://discord.com/api/webhooks/1/token', + enabled: true, + payload_template: template, + }); + expect(res.status).toBe(200); + + const get = await request(app).get('/api/agents').set('Cookie', adminCookie); + expect(get.status).toBe(200); + expect(get.body[0].payload_template).toBe(template); + }); + + it('rejects unknown template variables with a named error', async () => { + const res = await request(app).post('/api/agents').set('Cookie', adminCookie).send({ + type: 'discord', + url: 'https://discord.com/api/webhooks/1/token', + enabled: true, + payload_template: '{"a": "{{foo}}"}', + }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/Unknown template variable: \{\{foo\}\}/); + }); + + it('rejects invalid JSON, non-strings, and over-length templates', async () => { + const base = { + type: 'discord', + url: 'https://discord.com/api/webhooks/1/token', + enabled: true, + }; + const malformed = await request(app).post('/api/agents').set('Cookie', adminCookie).send({ + ...base, payload_template: '{', + }); + expect(malformed.status).toBe(400); + expect(malformed.body.error).toContain('valid JSON'); + + const nonString = await request(app).post('/api/agents').set('Cookie', adminCookie).send({ + ...base, payload_template: 42, + }); + expect(nonString.status).toBe(400); + expect(nonString.body.error).toContain('must be a string'); + + const over = await request(app).post('/api/agents').set('Cookie', adminCookie).send({ + ...base, payload_template: `{"msg":"${'x'.repeat(PAYLOAD_TEMPLATE_MAX_LENGTH - 9)}"}`, + }); + expect(over.status).toBe(400); + expect(over.body.error).toContain('8000 characters or fewer'); + + const atLimit = await request(app).post('/api/agents').set('Cookie', adminCookie).send({ + ...base, payload_template: `{"msg":"${'x'.repeat(PAYLOAD_TEMPLATE_MAX_LENGTH - 10)}"}`, + }); + expect(atLimit.status).toBe(200); + }); + + it('preserves the stored template when payload_template is omitted', async () => { + const db = DatabaseService.getInstance(); + db.upsertAgent(1, { + type: 'slack', + url: 'https://hooks.slack.com/services/T/B/X', + enabled: true, + payload_template: '{"text": "{{message}}"}', + }); + + const res = await request(app).post('/api/agents').set('Cookie', adminCookie).send({ + type: 'slack', + url: 'https://hooks.slack.com/services/T/B/X', + enabled: true, + }); + expect(res.status).toBe(200); + expect(db.getAgents(1).find(a => a.type === 'slack')?.payload_template).toBe('{"text": "{{message}}"}'); + }); + + it('clears the stored template with an empty string or null', async () => { + const db = DatabaseService.getInstance(); + db.upsertAgent(1, { + type: 'slack', + url: 'https://hooks.slack.com/services/T/B/X', + enabled: true, + payload_template: '{"text": "{{message}}"}', + }); + + const cleared = await request(app).post('/api/agents').set('Cookie', adminCookie).send({ + type: 'slack', + url: 'https://hooks.slack.com/services/T/B/X', + enabled: true, + payload_template: '', + }); + expect(cleared.status).toBe(200); + expect(db.getAgents(1).find(a => a.type === 'slack')?.payload_template).toBeNull(); + }); + + it('rejects a template write from a user without node:manage', async () => { + const res = await request(app).post('/api/agents').set('Cookie', viewerCookie).send({ + type: 'discord', + url: 'https://discord.com/api/webhooks/1/token', + enabled: true, + payload_template: '{"a": "{{level}}"}', + }); + expect(res.status).toBe(403); + }); + + it('rejects Apprise templates that carry urls, tag, or a non-object body', async () => { + const base = { + type: 'apprise', + url: 'http://apprise.local/notify', + enabled: true, + }; + const withUrls = await request(app).post('/api/agents').set('Cookie', adminCookie).send({ + ...base, config: { urls: 'discord://token@id' }, payload_template: '{"urls": "discord://token@id"}', + }); + expect(withUrls.status).toBe(400); + expect(withUrls.body.error).toContain('urls'); + + const withTag = await request(app).post('/api/agents').set('Cookie', adminCookie).send({ + ...base, config: { tags: 'ops' }, payload_template: '{"tag": "ops"}', + }); + expect(withTag.status).toBe(400); + + const scalar = await request(app).post('/api/agents').set('Cookie', adminCookie).send({ + ...base, config: { urls: 'discord://token@id' }, payload_template: '"{{message}}"', + }); + expect(scalar.status).toBe(400); + expect(scalar.body.error).toContain('render a JSON object'); + }); + + it('never exposes Apprise destination credentials through a templated agent', async () => { + const db = DatabaseService.getInstance(); + const serviceUrl = 'discord://webhook-id/webhook-token?token=query-secret'; + db.upsertAgent(1, { + type: 'apprise', + url: 'http://apprise.local/notify', + enabled: true, + config: JSON.stringify({ urls: serviceUrl }), + payload_template: '{"title": "{{level}}"}', + }); + + const res = await request(app).get('/api/agents').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body[0].payload_template).toBe('{"title": "{{level}}"}'); + expect(JSON.stringify(res.body)).not.toContain('webhook-token'); + expect(JSON.stringify(res.body)).not.toContain('query-secret'); + }); +}); diff --git a/backend/src/__tests__/alerts-api.test.ts b/backend/src/__tests__/alerts-api.test.ts index b9f73e36..31d5a3b1 100644 --- a/backend/src/__tests__/alerts-api.test.ts +++ b/backend/src/__tests__/alerts-api.test.ts @@ -7,6 +7,7 @@ import request from 'supertest'; import jwt from 'jsonwebtoken'; import bcrypt from 'bcrypt'; import { setupTestDb, cleanupTestDb, loginAsTestAdmin, TEST_JWT_SECRET } from './helpers/setupTestDb'; +import { PAYLOAD_TEMPLATE_MAX_LENGTH } from '../helpers/notificationPayloadTemplate'; let tmpDir: string; let app: import('express').Express; @@ -641,4 +642,113 @@ describe('POST /api/notifications/test', () => { vi.unstubAllGlobals(); } }); + + it('dispatches a templated test with substituted variables', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', fetchMock); + try { + const res = await request(app) + .post('/api/notifications/test') + .set('Cookie', authCookie) + .send({ + type: 'webhook', + url: 'https://example.com/hooks/sencho', + payload_template: '{"message": "{{message}}", "level": "{{level}}", "category": "{{category}}"}', + }); + expect(res.status).toBe(200); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(String(init.body))).toEqual({ + message: '🔌 Test Notification from Sencho!', + level: 'info', + category: 'system', + }); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('rejects an unknown variable on a templated test without dispatching', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + try { + const res = await request(app) + .post('/api/notifications/test') + .set('Cookie', authCookie) + .send({ + type: 'webhook', + url: 'https://example.com/hooks/sencho', + payload_template: '{"a": "{{nope}}"}', + }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/Unknown template variable/); + expect(fetchMock).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('rejects an over-length template on a templated test', async () => { + const res = await request(app) + .post('/api/notifications/test') + .set('Cookie', authCookie) + .send({ + type: 'webhook', + url: 'https://example.com/hooks/sencho', + payload_template: `{"msg":"${'x'.repeat(PAYLOAD_TEMPLATE_MAX_LENGTH - 9)}"}`, + }); + expect(res.status).toBe(400); + expect(res.body.error).toContain('8000 characters or fewer'); + }); + + it('keeps the built-in payload when the test template is blank', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', fetchMock); + try { + const res = await request(app) + .post('/api/notifications/test') + .set('Cookie', authCookie) + .send({ type: 'webhook', url: 'https://example.com/hooks/sencho', payload_template: ' ' }); + expect(res.status).toBe(200); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(String(init.body)) as { source?: string }; + expect(body.source).toBe('sencho'); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('merges Apprise destinations into a templated test dispatch', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', fetchMock); + try { + const res = await request(app) + .post('/api/notifications/test') + .set('Cookie', authCookie) + .send({ + type: 'apprise', + url: 'http://apprise.local/notify/test-key', + config: { tags: 'ops' }, + payload_template: '{"title": "{{level}}"}', + }); + expect(res.status).toBe(200); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(String(init.body))).toEqual({ title: 'info', tag: 'ops' }); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('rejects an Apprise templated test that carries urls', async () => { + const res = await request(app) + .post('/api/notifications/test') + .set('Cookie', authCookie) + .send({ + type: 'apprise', + url: 'http://apprise.local/notify', + config: { urls: 'discord://token@id' }, + payload_template: '{"urls": "discord://token@id"}', + }); + expect(res.status).toBe(400); + expect(res.body.error).toContain('urls'); + }); }); diff --git a/backend/src/__tests__/apprise-secret-at-rest.test.ts b/backend/src/__tests__/apprise-secret-at-rest.test.ts index 5a8a8888..c1cd33d4 100644 --- a/backend/src/__tests__/apprise-secret-at-rest.test.ts +++ b/backend/src/__tests__/apprise-secret-at-rest.test.ts @@ -207,4 +207,34 @@ describe('Apprise secrets at rest (downgrade-safe)', () => { expect(routes.find(r => r.name === 'discord-ok')!.channel_url).toContain('discord.com'); expect(routes.find(r => r.name === 'apprise-broken')!.channel_url).toBe(''); }); + + it('stores payload_template in plaintext while Apprise credentials stay encrypted', () => { + const db = DatabaseService.getInstance(); + const keySecret = 'SuperSecretKey99'; + const template = '{"title": "{{level}}", "body": "{{message}}"}'; + + db.upsertAgent(1, { + type: 'apprise', + url: `http://apprise.local/notify/${keySecret}`, + enabled: true, + config: '{}', + payload_template: template, + }); + + const raw = db.getDb().prepare('SELECT * FROM agents WHERE type = ?').get('apprise') as { + url: string; + config: string | null; + payload_template: string | null; + }; + expect(raw.url).toMatch(/^enc:/); + expect(raw.url).not.toContain(keySecret); + expect(raw.config).toMatch(/^enc:/); + expect(raw.payload_template).toBe(template); + + const apprise = db.getAgents(1).find(a => a.type === 'apprise')!; + expect(apprise.payload_template).toBe(template); + const pub = serializePublicAgent(apprise); + expect(pub.payload_template).toBe(template); + expect(JSON.stringify(pub)).not.toContain(keySecret); + }); }); diff --git a/backend/src/__tests__/notification-channel-config-migration.test.ts b/backend/src/__tests__/notification-channel-config-migration.test.ts index 73c7004e..4fdaabbb 100644 --- a/backend/src/__tests__/notification-channel-config-migration.test.ts +++ b/backend/src/__tests__/notification-channel-config-migration.test.ts @@ -105,4 +105,45 @@ describe('notification channel config column migration', () => { expect(agent2.url).toBe('https://discord.example/webhook/legacy'); expect(agent2.config).toBeNull(); }); + + it('adds the payload_template column to an existing config-era agents schema', () => { + scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-tpl-mig-')); + const dbPath = path.join(scratchDir, 'sencho.db'); + const seed = new Database(dbPath); + try { + seed.exec(` + CREATE TABLE agents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id INTEGER NOT NULL DEFAULT 0, + type TEXT NOT NULL, + url TEXT NOT NULL, + enabled INTEGER DEFAULT 0, + config TEXT NULL + ); + INSERT INTO agents (node_id, type, url, enabled, config) + VALUES (1, 'discord', 'https://discord.example/webhook/legacy', 1, NULL); + `); + } finally { + seed.close(); + } + + process.env.DATA_DIR = scratchDir; + resetDatabaseSingleton(); + const db = DatabaseService.getInstance(); + + const agentCols = db.getDb().prepare('PRAGMA table_info(agents)').all() as Array<{ name: string }>; + expect(agentCols.filter(c => c.name === 'payload_template')).toHaveLength(1); + + const legacy = db.getAgents(1).find(a => a.type === 'discord')!; + expect(legacy.url).toBe('https://discord.example/webhook/legacy'); + expect(legacy.payload_template).toBeNull(); + + db.upsertAgent(1, { + type: 'discord', + url: 'https://discord.example/webhook/legacy', + enabled: true, + payload_template: '{"title": "{{level}}"}', + }); + expect(db.getAgents(1).find(a => a.type === 'discord')!.payload_template).toBe('{"title": "{{level}}"}'); + }); }); diff --git a/backend/src/__tests__/notification-payload-template.test.ts b/backend/src/__tests__/notification-payload-template.test.ts new file mode 100644 index 00000000..1ad15d5a --- /dev/null +++ b/backend/src/__tests__/notification-payload-template.test.ts @@ -0,0 +1,164 @@ +/** + * Per-agent notification payload template validation and rendering. + */ +import { describe, it, expect } from 'vitest'; +import { + PAYLOAD_TEMPLATE_MAX_LENGTH, + assertPayloadTemplateAllowedForChannel, + renderPayloadTemplate, + templateTopLevelKeys, + validatePayloadTemplate, +} from '../helpers/notificationPayloadTemplate'; + +describe('validatePayloadTemplate', () => { + it('accepts undefined, null, and blank as null (built-in payload)', () => { + expect(validatePayloadTemplate(undefined)).toEqual({ ok: true, value: null }); + expect(validatePayloadTemplate(null)).toEqual({ ok: true, value: null }); + expect(validatePayloadTemplate('')).toEqual({ ok: true, value: null }); + expect(validatePayloadTemplate(' \n ')).toEqual({ ok: true, value: null }); + }); + + it('rejects non-string values', () => { + expect(validatePayloadTemplate(42)).toEqual({ ok: false, error: 'must be a string' }); + expect(validatePayloadTemplate({ a: 1 })).toEqual({ ok: false, error: 'must be a string' }); + }); + + it('accepts valid templates and returns the trimmed value', () => { + expect(validatePayloadTemplate('{"level": "{{level}}"}')).toEqual({ + ok: true, + value: '{"level": "{{level}}"}', + }); + expect(validatePayloadTemplate('"{{message}}"')).toEqual({ ok: true, value: '"{{message}}"' }); + }); + + it('accepts nested JSON, quoted keys, arrays, and variables mixed into strings', () => { + expect(validatePayloadTemplate('{"a": {"b": "{{level}}"}}').ok).toBe(true); + expect(validatePayloadTemplate('{"{{level}}": 1}').ok).toBe(true); + expect(validatePayloadTemplate('["{{message}}"]').ok).toBe(true); + expect(validatePayloadTemplate('{"a": "pre {{level}} post"}').ok).toBe(true); + expect(validatePayloadTemplate('{"a": "{{level}} and {{message}}"}').ok).toBe(true); + expect(validatePayloadTemplate('{"a": "{{level}}!"}').ok).toBe(true); + expect(validatePayloadTemplate('{"empty": {}}').ok).toBe(true); + }); + + it('rejects unterminated or stray open variable tokens', () => { + const unclosed = validatePayloadTemplate('{"a": "{{message}"}'); + expect(unclosed.ok).toBe(false); + if (!unclosed.ok) expect(unclosed.error).toContain('complete {{name}} token'); + + const strayOpen = validatePayloadTemplate('{"a": "{{"}'); + expect(strayOpen.ok).toBe(false); + }); + + it('rejects a bare variable in a non-string value position', () => { + const result = validatePayloadTemplate('{"a": {{level}}}'); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain('valid JSON'); + }); + + it('rejects unknown variables and names all of them', () => { + const result = validatePayloadTemplate('{"a": "{{foo}}"}'); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain('{{foo}}'); + expect(result.error).toContain('Allowed variables: level, message, category, timestamp, stack_name, actor'); + } + + const multi = validatePayloadTemplate('{"a": "{{foo}}", "b": "{{bar}}"}'); + expect(multi.ok).toBe(false); + if (!multi.ok) { + expect(multi.error).toContain('{{foo}}'); + expect(multi.error).toContain('{{bar}}'); + } + }); + + it('rejects malformed JSON', () => { + const result = validatePayloadTemplate('{'); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain('valid JSON'); + }); + + it('enforces the length cap after trimming, before substitution', () => { + // '{"msg":"' is 8 chars and '"}' is 2, so the payload is 10 + repeats. + const atLimit = `{"msg":"${'x'.repeat(PAYLOAD_TEMPLATE_MAX_LENGTH - 10)}"}`; + expect(atLimit.length).toBe(PAYLOAD_TEMPLATE_MAX_LENGTH); + expect(validatePayloadTemplate(atLimit).ok).toBe(true); + + const over = `{"msg":"${'x'.repeat(PAYLOAD_TEMPLATE_MAX_LENGTH - 9)}"}`; + expect(over.length).toBe(PAYLOAD_TEMPLATE_MAX_LENGTH + 1); + const result = validatePayloadTemplate(over); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain(`${PAYLOAD_TEMPLATE_MAX_LENGTH} characters or fewer`); + }); +}); + +describe('templateTopLevelKeys', () => { + it('returns the top-level keys of the placeholder-substituted object', () => { + expect(templateTopLevelKeys('{"a": 1, "b": "{{level}}"}')).toEqual(['a', 'b']); + }); + + it('returns an empty array for non-object or unparseable documents', () => { + expect(templateTopLevelKeys('"{{message}}"')).toEqual([]); + expect(templateTopLevelKeys('[1, 2]')).toEqual([]); + expect(templateTopLevelKeys('{')).toEqual([]); + expect(templateTopLevelKeys('{}')).toEqual([]); + }); +}); + +describe('assertPayloadTemplateAllowedForChannel', () => { + it('allows any template on non-Apprise channels', () => { + expect(assertPayloadTemplateAllowedForChannel('{{message}}', 'discord')).toBeNull(); + expect(assertPayloadTemplateAllowedForChannel('{"urls": "x"}', 'webhook')).toBeNull(); + }); + + it('rejects Apprise templates carrying urls or tag', () => { + const urls = assertPayloadTemplateAllowedForChannel('{"urls": "discord://x"}', 'apprise'); + expect(urls).toContain('urls'); + const tag = assertPayloadTemplateAllowedForChannel('{"tag": "ops"}', 'apprise'); + expect(tag).toContain('tag'); + }); + + it('requires Apprise templates to render a non-empty JSON object', () => { + expect(assertPayloadTemplateAllowedForChannel('{{message}}', 'apprise')).toContain('render a JSON object'); + expect(assertPayloadTemplateAllowedForChannel('"{{message}}"', 'apprise')).toContain('render a JSON object'); + expect(assertPayloadTemplateAllowedForChannel('{}', 'apprise')).toContain('render a JSON object'); + expect(assertPayloadTemplateAllowedForChannel('{"title": "{{level}}"}', 'apprise')).toBeNull(); + }); +}); + +describe('renderPayloadTemplate', () => { + it('JSON-escapes values so quotes, newlines, and backslashes survive', () => { + const message = 'say "hi"\nline\\two'; + const rendered = renderPayloadTemplate('{"message": "{{message}}"}', { message }) as { + message: string; + }; + expect(rendered.message).toBe(message); + }); + + it('substitutes missing context with an empty string', () => { + expect(renderPayloadTemplate('{"message": "{{message}}"}', {})).toEqual({ message: '' }); + }); + + it('supports quoted keys, arrays, and variables mixed into strings', () => { + expect(renderPayloadTemplate('{"{{level}}": 1}', { level: 'info' })).toEqual({ info: 1 }); + expect(renderPayloadTemplate('["{{level}}"]', { level: 'info' })).toEqual(['info']); + expect(renderPayloadTemplate('{"a": "x {{level}} y"}', { level: 'info' })).toEqual({ a: 'x info y' }); + }); + + it('JSON-escapes values substituted inside a string', () => { + const message = 'say "hi"\nline\\two'; + const rendered = renderPayloadTemplate('{"a": "x {{message}} y"}', { message }) as { a: string }; + expect(rendered.a).toBe(`x ${message} y`); + }); + + it('does not re-substitute braces inside substituted values', () => { + const rendered = renderPayloadTemplate('{"message": "{{message}}"}', { + message: 'contains {{level}} literal', + }) as { message: string }; + expect(rendered.message).toBe('contains {{level}} literal'); + }); + + it('throws on a template that fails to parse after substitution', () => { + expect(() => renderPayloadTemplate('{"a":', {})).toThrow('Templated payload rendered invalid JSON'); + }); +}); diff --git a/backend/src/__tests__/notification-template-dispatch.test.ts b/backend/src/__tests__/notification-template-dispatch.test.ts new file mode 100644 index 00000000..fccb7e01 --- /dev/null +++ b/backend/src/__tests__/notification-template-dispatch.test.ts @@ -0,0 +1,406 @@ +/** + * Templated payload dispatch through NotificationService: variable + * substitution, retry stability, ntfy JSON publish, Apprise destination + * merging, non-goal enforcement (routes never templated), and regression + * guards for untemplated agents. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const { + mockGetEnabledNotificationRoutes, + mockGetEnabledNotificationSuppressionRules, + mockGetEnabledAgents, + mockGetStackLabelIds, + mockAddNotificationHistory, + mockUpdateNotificationDispatchError, + mockGetGlobalSettings, +} = vi.hoisted(() => ({ + mockGetEnabledNotificationRoutes: vi.fn().mockReturnValue([]), + mockGetEnabledNotificationSuppressionRules: vi.fn().mockReturnValue([]), + mockGetEnabledAgents: vi.fn().mockReturnValue([]), + mockGetStackLabelIds: vi.fn().mockReturnValue([]), + mockAddNotificationHistory: vi.fn().mockReturnValue({ + id: 42, + level: 'error', + message: 'down', + timestamp: 1700000000000, + is_read: 0, + }), + mockUpdateNotificationDispatchError: vi.fn(), + mockGetGlobalSettings: vi.fn().mockReturnValue({ notification_dispatch_retries: '0' }), +})); + +vi.mock('../services/DatabaseService', () => ({ + DatabaseService: { + getInstance: () => ({ + getEnabledNotificationRoutes: mockGetEnabledNotificationRoutes, + getEnabledNotificationSuppressionRules: mockGetEnabledNotificationSuppressionRules, + getEnabledAgents: mockGetEnabledAgents, + getStackLabelIds: mockGetStackLabelIds, + addNotificationHistory: mockAddNotificationHistory, + updateNotificationDispatchError: mockUpdateNotificationDispatchError, + getGlobalSettings: mockGetGlobalSettings, + }), + }, +})); + +vi.mock('../services/NodeRegistry', () => ({ + NodeRegistry: { + getInstance: () => ({ + getDefaultNodeId: () => 1, + getComposeDir: () => '/app/compose', + }), + }, +})); + +vi.mock('../services/StackActivityMetricsService', () => ({ + StackActivityMetricsService: { + getInstance: () => ({ record: vi.fn() }), + }, +})); + +import { NotificationService } from '../services/NotificationService'; + +const WEBHOOK_URL = 'https://example.com/hooks/sencho'; + +function makeRoute(overrides: Record = {}) { + return { + id: 1, + name: 'Prod Discord', + node_id: null as number | null, + stack_patterns: [] as string[], + label_ids: null as number[] | null, + categories: null as string[] | null, + levels: null as ('info' | 'warning' | 'error')[] | null, + channel_type: 'discord' as const, + channel_url: 'https://discord.com/api/webhooks/1/token', + priority: 0, + enabled: true, + created_at: Date.now(), + updated_at: Date.now(), + ...overrides, + }; +} + +function makeAgent(overrides: Record = {}) { + return { + id: 1, + node_id: 1, + type: 'webhook' as const, + url: WEBHOOK_URL, + enabled: true, + config: null as string | null, + payload_template: null as string | null, + ...overrides, + }; +} + +describe('templated payload dispatch', () => { + let svc: NotificationService; + let mockFetch: ReturnType; + + beforeEach(() => { + (NotificationService as unknown as { instance?: NotificationService }).instance = undefined; + NotificationService.setRetryDelayMsForTests(0); + svc = NotificationService.getInstance(); + mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', mockFetch); + mockGetEnabledNotificationRoutes.mockReturnValue([]); + mockGetEnabledNotificationSuppressionRules.mockReturnValue([]); + mockGetEnabledAgents.mockReturnValue([]); + mockGetStackLabelIds.mockReturnValue([]); + mockUpdateNotificationDispatchError.mockClear(); + mockAddNotificationHistory.mockClear(); + mockGetGlobalSettings.mockReset(); + mockGetGlobalSettings.mockReturnValue({ notification_dispatch_retries: '0' }); + }); + + afterEach(() => { + NotificationService.setRetryDelayMsForTests(1000); + vi.unstubAllGlobals(); + }); + + it('substitutes all template variables from the dispatch context', async () => { + mockGetEnabledAgents.mockReturnValue([ + makeAgent({ + payload_template: + '{"level":"{{level}}","message":"{{message}}","category":"{{category}}",' + + '"timestamp":"{{timestamp}}","stack_name":"{{stack_name}}","actor":"{{actor}}"}', + }), + ]); + + await svc.dispatchAlert('error', 'monitor_alert', 'down', { stackName: 'web', actor: 'boris' }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(init.headers).toMatchObject({ 'Content-Type': 'application/json' }); + expect(JSON.parse(String(init.body))).toEqual({ + level: 'error', + message: 'down', + category: 'monitor_alert', + timestamp: new Date(1700000000000).toISOString(), + stack_name: 'web', + actor: 'boris', + }); + }); + + it('renders the persisted history-row timestamp, stable across retries', async () => { + mockGetGlobalSettings.mockReturnValue({ notification_dispatch_retries: '1' }); + mockGetEnabledAgents.mockReturnValue([ + makeAgent({ payload_template: '{"timestamp": "{{timestamp}}", "message": "{{message}}"}' }), + ]); + mockFetch + .mockResolvedValueOnce({ ok: false, status: 502 }) + .mockResolvedValueOnce({ ok: true, status: 200 }); + + await svc.dispatchAlert('error', 'monitor_alert', 'down'); + + expect(mockFetch).toHaveBeenCalledTimes(2); + const first = mockFetch.mock.calls[0][1] as RequestInit; + const second = mockFetch.mock.calls[1][1] as RequestInit; + expect(String(first.body)).toBe(String(second.body)); + expect(JSON.parse(String(first.body))).toEqual({ + timestamp: new Date(1700000000000).toISOString(), + message: 'down', + }); + }); + + it('treats a 4xx on a templated agent as non-retryable even with extras configured', async () => { + mockGetGlobalSettings.mockReturnValue({ notification_dispatch_retries: '3' }); + mockGetEnabledAgents.mockReturnValue([makeAgent({ payload_template: '{"m":"{{message}}"}' })]); + mockFetch.mockResolvedValue({ ok: false, status: 400 }); + + await svc.dispatchAlert('error', 'monitor_alert', 'down'); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockUpdateNotificationDispatchError).toHaveBeenCalledWith( + 42, + expect.stringContaining('webhook rejected templated payload with HTTP 400'), + ); + }); + + it('posts JSON to a normalized ntfy URL without plaintext headers', async () => { + mockGetEnabledAgents.mockReturnValue([ + makeAgent({ + type: 'ntfy', + url: 'https://ntfy.sh/mytopic/', + payload_template: '{"message": "{{message}}"}', + }), + ]); + + await svc.dispatchAlert('warning', 'stack_restarted', 'up again', { stackName: 'blog' }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [target, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(target).toBe('https://ntfy.sh/mytopic'); + expect(init.headers).toMatchObject({ 'Content-Type': 'application/json' }); + expect(init.headers).not.toHaveProperty('Title'); + expect(init.headers).not.toHaveProperty('Priority'); + expect(init.headers).not.toHaveProperty('Tags'); + expect(JSON.parse(String(init.body))).toEqual({ message: 'up again' }); + }); + + it('translates ntfy URL userinfo into Basic authorization for templated posts', async () => { + mockGetEnabledAgents.mockReturnValue([ + makeAgent({ + type: 'ntfy', + url: 'https://user:pass@ntfy.sh/mytopic', + payload_template: '{"message": "{{message}}"}', + }), + ]); + + await svc.dispatchAlert('info', 'system', 'hello'); + + const [target, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(target).toBe('https://ntfy.sh/mytopic'); + expect(init.headers).toMatchObject({ Authorization: 'Basic dXNlcjpwYXNz' }); + expect(JSON.parse(String(init.body))).toEqual({ message: 'hello' }); + }); + + it('merges stored Apprise destination URLs into the rendered body', async () => { + mockGetEnabledAgents.mockReturnValue([ + makeAgent({ + type: 'apprise', + url: 'http://apprise.local/notify', + config: JSON.stringify({ urls: 'discord://token@id' }), + payload_template: '{"title": "{{level}}", "body": "{{message}}"}', + }), + ]); + + await svc.dispatchAlert('error', 'monitor_alert', 'down'); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(String(init.body))).toEqual({ + title: 'error', + body: 'down', + urls: 'discord://token@id', + }); + }); + + it('treats an Apprise 204 on the templated path as a non-retryable no-delivery', async () => { + mockGetGlobalSettings.mockReturnValue({ notification_dispatch_retries: '2' }); + mockGetEnabledAgents.mockReturnValue([ + makeAgent({ + type: 'apprise', + url: 'http://apprise.local/notify', + config: JSON.stringify({ urls: 'discord://token@id' }), + payload_template: '{"title": "{{level}}"}', + }), + ]); + mockFetch.mockResolvedValue({ ok: true, status: 204 }); + + await svc.dispatchAlert('error', 'monitor_alert', 'down'); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockUpdateNotificationDispatchError).toHaveBeenCalledWith( + 42, + expect.stringContaining('no delivery (HTTP 204)'), + ); + }); + + it('does not fetch and records a non-retryable error for a corrupt stored template', async () => { + mockGetEnabledAgents.mockReturnValue([makeAgent({ payload_template: '{"a":' })]); + + await svc.dispatchAlert('error', 'monitor_alert', 'down'); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockUpdateNotificationDispatchError).toHaveBeenCalledWith( + 42, + expect.stringContaining('Templated payload could not be rendered'), + ); + }); + + it('ignores a stored agent template when a notification route matches', async () => { + mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute()]); + mockGetEnabledAgents.mockReturnValue([ + makeAgent({ payload_template: '{"custom": "{{level}}"}' }), + ]); + + await svc.dispatchAlert('error', 'monitor_alert', 'down'); + + const [target, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(target).toBe('https://discord.com/api/webhooks/1/token'); + const body = JSON.parse(String(init.body)) as { embeds?: unknown[]; custom?: unknown }; + expect(body.custom).toBeUndefined(); + expect(Array.isArray(body.embeds)).toBe(true); + }); + + it('merges stored Apprise tags for keyed endpoints into the rendered body', async () => { + mockGetEnabledAgents.mockReturnValue([ + makeAgent({ + type: 'apprise', + url: 'http://apprise.local/notify/test-key', + config: JSON.stringify({ tags: 'ops' }), + payload_template: '{"title": "{{level}}"}', + }), + ]); + + await svc.dispatchAlert('error', 'monitor_alert', 'down'); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(String(init.body))).toEqual({ title: 'error', tag: 'ops' }); + }); + + it('does not merge a tag for keyed Apprise endpoints without tags', async () => { + mockGetEnabledAgents.mockReturnValue([ + makeAgent({ + type: 'apprise', + url: 'http://apprise.local/notify/test-key', + config: '{}', + payload_template: '{"title": "{{level}}"}', + }), + ]); + + await svc.dispatchAlert('error', 'monitor_alert', 'down'); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(String(init.body))).toEqual({ title: 'error' }); + }); + + it('treats a whitespace-only stored template as no template', async () => { + mockGetEnabledAgents.mockReturnValue([makeAgent({ type: 'webhook', payload_template: ' ' })]); + + await svc.dispatchAlert('error', 'monitor_alert', 'down'); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(String(init.body)) as { source?: string }; + expect(body.source).toBe('sencho'); + }); + + it('classifies a network failure on the templated path as retryable', async () => { + mockGetGlobalSettings.mockReturnValue({ notification_dispatch_retries: '1' }); + mockGetEnabledAgents.mockReturnValue([makeAgent({ payload_template: '{"m": "{{message}}"}' })]); + mockFetch + .mockRejectedValueOnce(new Error('ECONNREFUSED')) + .mockResolvedValueOnce({ ok: true, status: 200 }); + + await svc.dispatchAlert('error', 'monitor_alert', 'down'); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockUpdateNotificationDispatchError).not.toHaveBeenCalled(); + }); + + it('rejects a non-object rendered template on the Apprise path without fetching', async () => { + mockGetEnabledAgents.mockReturnValue([ + makeAgent({ + type: 'apprise', + url: 'http://apprise.local/notify', + config: '{}', + payload_template: '"{{message}}"', + }), + ]); + + await svc.dispatchAlert('error', 'monitor_alert', 'down'); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockUpdateNotificationDispatchError).toHaveBeenCalledWith( + 42, + expect.stringContaining('must render a JSON object'), + ); + }); + + it('rejects an invalid stored Apprise config on the templated path without fetching', async () => { + mockGetEnabledAgents.mockReturnValue([ + makeAgent({ + type: 'apprise', + url: 'http://apprise.local/notify', + config: '{not-json', + payload_template: '{"title": "{{level}}"}', + }), + ]); + + await svc.dispatchAlert('error', 'monitor_alert', 'down'); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockUpdateNotificationDispatchError).toHaveBeenCalledWith( + 42, + expect.stringContaining('Stored Apprise configuration is invalid'), + ); + }); + + it('keeps the built-in payload for an untemplated agent', async () => { + mockGetEnabledAgents.mockReturnValue([makeAgent({ type: 'webhook' })]); + + await svc.dispatchAlert('error', 'monitor_alert', 'down'); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(String(init.body)) as { level: string; message: string; source: string }; + expect(body).toMatchObject({ level: 'error', message: 'down', source: 'sencho' }); + }); + + it('testDispatch renders the template with the test message and system category', async () => { + await svc.testDispatch( + 'webhook', + WEBHOOK_URL, + undefined, + '{"message": "{{message}}", "level": "{{level}}", "category": "{{category}}"}', + ); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(String(init.body))).toEqual({ + message: '🔌 Test Notification from Sencho!', + level: 'info', + category: 'system', + }); + }); +}); diff --git a/backend/src/helpers/notificationPayloadTemplate.ts b/backend/src/helpers/notificationPayloadTemplate.ts new file mode 100644 index 00000000..c7e400c5 --- /dev/null +++ b/backend/src/helpers/notificationPayloadTemplate.ts @@ -0,0 +1,175 @@ +/** + * Per-agent notification payload templates. + * + * A template is an optional user-authored JSON document on a notification + * agent. When set, the rendered JSON replaces the built-in body for that + * channel. Substitution is plain `{{key}}` replacement (no templating + * engine). A variable must appear inside a JSON string: as the whole string + * (`"{{message}}"`) or glued to other text (`"status: {{level}}"`); every + * occurrence is replaced with the JSON-escaped value, so quotes or newlines + * inside a value cannot break the document. Validation substitutes every + * known variable with a placeholder and requires the result to parse, so a + * save-valid template stays valid when real values are substituted. + */ + +export const PAYLOAD_TEMPLATE_VARS = ['level', 'message', 'category', 'timestamp', 'stack_name', 'actor'] as const; +export type PayloadTemplateVar = (typeof PAYLOAD_TEMPLATE_VARS)[number]; + +/** Upper bound on template length, enforced before substitution. */ +export const PAYLOAD_TEMPLATE_MAX_LENGTH = 8000; + +/** Placeholders used in place of every known variable during validation. */ +const QUOTED_PLACEHOLDER = '"__sencho_template_value__"'; +const BARE_PLACEHOLDER = '__sencho_template_value__'; + +/** + * Matches a known variable token, derived from PAYLOAD_TEMPLATE_VARS so the + * vocabulary has one source of truth. The quoted alternative comes first so + * `"{{message}}"` is consumed as one unit (the template's quotes are + * replaced by the injected JSON string literal); a bare `{{message}}` is + * matched by the second alternative wherever it sits inside a string and is + * replaced with the escaped string content, without surrounding quotes. + */ +const TEMPLATE_VAR_ALTERNATION = PAYLOAD_TEMPLATE_VARS.join('|'); +const TEMPLATE_VAR_REGEX = new RegExp( + `"\\{\\{(${TEMPLATE_VAR_ALTERNATION})\\}\\}"|\\{\\{(${TEMPLATE_VAR_ALTERNATION})\\}\\}`, + 'g', +); +const UNKNOWN_VAR_REGEX = /\{\{([^{}]+)\}\}/g; + +/** Escaped JSON string content without surrounding quotes (for in-string substitution). */ +function escapeStringContent(value: string | undefined): string { + const literal = JSON.stringify(value ?? ''); + return literal.slice(1, -1); +} + +function substitutePlaceholders(template: string): string { + return substituteVars(template, (name, quoted) => (quoted ? QUOTED_PLACEHOLDER : BARE_PLACEHOLDER)); +} + +function substituteVars(template: string, inject: (name: string, quoted: boolean) => string): string { + // The two alternatives each capture the variable name into a different + // group (1 quoted, 2 bare), so read whichever matched. + return template.replace(TEMPLATE_VAR_REGEX, (_match, quoted?: string, bare?: string) => + inject(quoted ?? bare ?? '', quoted !== undefined), + ); +} + +export type PayloadTemplateValidation = + | { ok: true; value: string | null } + | { ok: false; error: string }; + +/** + * Validate a payload template. `undefined`/null/blank (after trim) resolve to + * null (built-in payload). Otherwise the template must be a string, at most + * PAYLOAD_TEMPLATE_MAX_LENGTH characters, reference only known variables as + * complete `{{var}}` tokens with no unterminated `{{` left over, and parse + * as JSON after placeholder substitution. + */ +export function validatePayloadTemplate(raw: unknown): PayloadTemplateValidation { + if (raw === undefined || raw === null) return { ok: true, value: null }; + if (typeof raw !== 'string') return { ok: false, error: 'must be a string' }; + const trimmed = raw.trim(); + if (trimmed === '') return { ok: true, value: null }; + if (trimmed.length > PAYLOAD_TEMPLATE_MAX_LENGTH) { + return { ok: false, error: `must be ${PAYLOAD_TEMPLATE_MAX_LENGTH} characters or fewer` }; + } + + const substituted = substitutePlaceholders(trimmed); + const unknownTokens = [...substituted.matchAll(UNKNOWN_VAR_REGEX)].map(m => m[1]); + if (unknownTokens.length > 0) { + const named = unknownTokens.map(token => `{{${token}}}`).join(', '); + return { + ok: false, + error: `Unknown template variable: ${named}. Allowed variables: ${PAYLOAD_TEMPLATE_VARS.join(', ')}.`, + }; + } + // A leftover `{{` can only be an unterminated or stray variable token + // (complete unknown tokens are rejected above); `}}` alone is legitimate + // JSON, for example a nested object's closing braces. + if (substituted.includes('{{')) { + return { ok: false, error: 'must not contain an unterminated or stray {{token}}; each variable must be a complete {{name}} token' }; + } + + try { + JSON.parse(substituted); + } catch { + return { ok: false, error: 'must be valid JSON after substituting template variables' }; + } + return { ok: true, value: trimmed }; +} + +/** + * Top-level keys of the parsed template document after placeholder + * substitution. Empty when the document does not parse as a JSON object. + * Used to keep Apprise destination fields (`urls`/`tag`) managed by the + * channel configuration rather than the template. + */ +export function templateTopLevelKeys(template: string): string[] { + const substituted = substitutePlaceholders(template); + try { + const parsed = JSON.parse(substituted) as unknown; + if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) { + return Object.keys(parsed as Record); + } + } catch { + // Invalid JSON is rejected by validatePayloadTemplate before this is called. + } + return []; +} + +/** + * Channel-specific template restrictions shared by both write endpoints. + * Apprise destinations (`urls`/`tag`) are managed by the channel fields and + * merged server-side at dispatch, so Apprise templates must render a + * non-empty JSON object and must not carry those keys. Returns an error + * message, or null when the template is allowed. + */ +export function assertPayloadTemplateAllowedForChannel(template: string, type: string): string | null { + if (type !== 'apprise') return null; + const keys = templateTopLevelKeys(template); + const forbidden = keys.filter(key => key === 'urls' || key === 'tag'); + if (keys.length === 0) return 'must render a JSON object'; + if (forbidden.length > 0) { + return `must not include ${forbidden.join(' or ')}; Apprise destinations are managed by the channel fields`; + } + return null; +} + +/** + * Resolve a payload template write for either route. `raw` undefined keeps + * the stored value; otherwise validate, apply the channel-specific gate, and + * return the normalized template (null when blank). + */ +export function resolvePayloadTemplate( + raw: unknown, + stored: string | null | undefined, + type: string, +): { ok: true; value: string | null } | { ok: false; error: string } { + if (raw === undefined) return { ok: true, value: stored ?? null }; + const validated = validatePayloadTemplate(raw); + if (!validated.ok) return validated; + if (validated.value !== null) { + const channelErr = assertPayloadTemplateAllowedForChannel(validated.value, type); + if (channelErr) return { ok: false, error: channelErr }; + } + return validated; +} + +/** + * Render a validated template with concrete values. Missing context becomes + * an empty string. Values are JSON-escaped via JSON.stringify, so the result + * parses whenever the template passed validation. Throws a plain Error on + * parse failure (callers that deliver externally wrap it as a non-retryable + * delivery error). + */ +export function renderPayloadTemplate(template: string, vars: Record): unknown { + const rendered = substituteVars(template, (name, quoted) => + quoted ? JSON.stringify(vars[name] ?? '') : escapeStringContent(vars[name]), + ); + try { + return JSON.parse(rendered) as unknown; + } catch { + throw new Error('Templated payload rendered invalid JSON'); + } +} diff --git a/backend/src/routes/agents.ts b/backend/src/routes/agents.ts index b7994307..ee70f8f3 100644 --- a/backend/src/routes/agents.ts +++ b/backend/src/routes/agents.ts @@ -12,6 +12,7 @@ import { serializePublicAgent, validateNotificationChannel, } from '../helpers/notificationChannels'; +import { resolvePayloadTemplate } from '../helpers/notificationPayloadTemplate'; export const agentsRouter = Router(); @@ -42,6 +43,19 @@ agentsRouter.post('/', authMiddleware, async (req: Request, res: Response): Prom const existing = DatabaseService.getInstance().getAgents(nodeId).find(agent => agent.type === type); const effectiveUrl = url === undefined ? existing?.url : url; + // Optional payload template: omitted preserves the stored value; blank + // clears it; Apprise templates may not carry urls/tag (destinations are + // managed by the channel fields and merged server-side at dispatch). + const resolvedTemplate = resolvePayloadTemplate( + req.body.payload_template, + existing?.payload_template ?? null, + type, + ); + if (!resolvedTemplate.ok) { + res.status(400).json({ error: `payload_template ${resolvedTemplate.error}` }); + return; + } + let effectiveConfig: unknown = config ?? null; if (type === 'apprise' && config === undefined && existing) { const resolved = resolvePreservedAppriseConfig(typeof effectiveUrl === 'string' ? effectiveUrl : existing.url, existing.config); @@ -58,6 +72,7 @@ agentsRouter.post('/', authMiddleware, async (req: Request, res: Response): Prom url: effectiveUrl.trim(), enabled, config: type === 'apprise' ? normalizeAppriseStoredJson(effectiveUrl.trim(), effectiveConfig) : null, + payload_template: resolvedTemplate.value, }); console.log('[Agents] Agent %s updated', sanitizeForLog(type)); if (isDebugEnabled()) console.log('[Agents:diag] Agent %s upsert: enabled=%s', sanitizeForLog(type), sanitizeForLog(enabled)); diff --git a/backend/src/routes/notifications.ts b/backend/src/routes/notifications.ts index fa61b0a6..f84c8d58 100644 --- a/backend/src/routes/notifications.ts +++ b/backend/src/routes/notifications.ts @@ -26,6 +26,7 @@ import { parseNotificationSchedule, type NotificationSchedule, } from '../helpers/notificationSchedule'; +import { resolvePayloadTemplate } from '../helpers/notificationPayloadTemplate'; import { deleteSuppressionRuleFromFleet, syncSuppressionRuleToFleet, @@ -341,14 +342,19 @@ notificationsRouter.delete('/', authMiddleware, async (req: Request, res: Respon notificationsRouter.post('/test', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; try { - const { type, url, config } = req.body; + const { type, url, config, payload_template } = req.body; if (!type || !(NOTIFICATION_CHANNEL_TYPES as readonly string[]).includes(type)) { res.status(400).json({ error: `type must be ${NOTIFICATION_CHANNEL_TYPES.join(', ')}` }); return; } const channelErr = validateNotificationChannel(type, url, config); if (channelErr) { res.status(400).json({ error: `url ${channelErr}` }); return; } - await NotificationService.getInstance().testDispatch(type, url, config); + const resolvedTemplate = resolvePayloadTemplate(payload_template, null, type); + if (!resolvedTemplate.ok) { + res.status(400).json({ error: `payload_template ${resolvedTemplate.error}` }); + return; + } + await NotificationService.getInstance().testDispatch(type, url, config, resolvedTemplate.value); res.json({ success: true }); } catch (error) { res.status(500).json({ error: 'Test failed', details: getErrorMessage(error, String(error)) }); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 1fac8fd8..9dd76eed 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -30,6 +30,8 @@ export interface Agent { url: string; enabled: boolean; config?: string | null; + /** Optional user-authored JSON payload template; null = built-in payload. */ + payload_template?: string | null; } export interface GlobalSetting { @@ -2436,6 +2438,7 @@ export class DatabaseService { private migrateNotificationChannelConfig(): void { this.tryAddColumn('agents', 'config', 'TEXT NULL'); + this.tryAddColumn('agents', 'payload_template', 'TEXT NULL'); this.tryAddColumn('notification_routes', 'config', 'TEXT NULL'); } @@ -2995,11 +2998,11 @@ export class DatabaseService { const stored = this.storeAppriseFields(agent.type === 'apprise', agent.url, agent.config); const existing = this.db.prepare('SELECT id FROM agents WHERE node_id = ? AND type = ?').get(nodeId, agent.type) as any; if (existing) { - const stmt = this.db.prepare('UPDATE agents SET url = ?, enabled = ?, config = ? WHERE node_id = ? AND type = ?'); - stmt.run(stored.url, agent.enabled ? 1 : 0, stored.config, nodeId, agent.type); + const stmt = this.db.prepare('UPDATE agents SET url = ?, enabled = ?, config = ?, payload_template = ? WHERE node_id = ? AND type = ?'); + stmt.run(stored.url, agent.enabled ? 1 : 0, stored.config, agent.payload_template ?? null, nodeId, agent.type); } else { - const stmt = this.db.prepare('INSERT INTO agents (node_id, type, url, enabled, config) VALUES (?, ?, ?, ?, ?)'); - stmt.run(nodeId, agent.type, stored.url, agent.enabled ? 1 : 0, stored.config); + const stmt = this.db.prepare('INSERT INTO agents (node_id, type, url, enabled, config, payload_template) VALUES (?, ?, ?, ?, ?, ?)'); + stmt.run(nodeId, agent.type, stored.url, agent.enabled ? 1 : 0, stored.config, agent.payload_template ?? null); } } diff --git a/backend/src/services/NotificationService.ts b/backend/src/services/NotificationService.ts index d5da1a95..e4a09b99 100644 --- a/backend/src/services/NotificationService.ts +++ b/backend/src/services/NotificationService.ts @@ -21,6 +21,7 @@ import { validateNotificationChannel, } from '../helpers/notificationChannels'; import { parseNotificationDispatchRetries } from '../helpers/notificationDispatchRetries'; +import { renderPayloadTemplate } from '../helpers/notificationPayloadTemplate'; export type NotificationCategory = | 'deploy_success' @@ -92,6 +93,22 @@ export class NotificationDeliveryError extends Error { } } +/** + * Per-dispatch extras for templated payloads. The template replaces the + * built-in body; all variable values are fixed at dispatch time (level and + * message come from the dispatch arguments, the rest from this object), so + * retries of the same dispatch send an identical body. + */ +export interface NotificationDispatchOptions { + category?: string; + stackName?: string; + actor?: string; + /** Dispatch timestamp (epoch ms); rendered as ISO-8601. Resolved once per dispatch by callers. */ + timestampMs: number; + /** User-authored payload template; null/blank keeps the built-in body. */ + template?: string | null; +} + export class NotificationService { private static instance: NotificationService; private dbService: DatabaseService; @@ -317,7 +334,13 @@ export class NotificationService { if (isDebugEnabled()) console.log(`[Notify:diag] Falling back to ${agents.length} global agent(s)`); await Promise.allSettled( agents.map(agent => - this.sendWithRetries(agent.type, agent.url, level, sanitized, agent.config, retries) + this.sendWithRetries(agent.type, agent.url, level, sanitized, agent.config, retries, { + category, + stackName, + actor, + timestampMs: notification.timestamp, + template: agent.payload_template ?? null, + }) .then(() => { if (isDebugEnabled()) console.log(`[Notify:diag] Dispatched ${level} via global agent (${agent.type})`); }) @@ -377,12 +400,13 @@ export class NotificationService { message: string, config: string | null | undefined, retries: number, + options?: NotificationDispatchOptions, ): Promise { const totalAttempts = 1 + retries; let lastError: NotificationDeliveryError | undefined; for (let attempt = 0; attempt < totalAttempts; attempt++) { try { - await this.sendToChannel(type, url, level, message, config); + await this.sendToChannel(type, url, level, message, config, options); return; } catch (error) { const deliveryError = error instanceof NotificationDeliveryError @@ -403,7 +427,13 @@ export class NotificationService { throw lastError ?? new NotificationDeliveryError('Notification delivery failed', null, false); } - private async sendToChannel(type: string, url: string, level: 'info' | 'warning' | 'error', message: string, config?: string | null): Promise { + private async sendToChannel(type: string, url: string, level: 'info' | 'warning' | 'error', message: string, config?: string | null, options?: NotificationDispatchOptions): Promise { + // A stored template replaces the built-in body for every channel + // type; a whitespace-only stored template counts as no template. + if (options?.template && options.template.trim()) { + await this.sendTemplatedPayload(type, url, level, message, config, options); + return; + } if (type === 'discord') { await this.sendDiscordWebhook(url, level, message); } else if (type === 'slack') { @@ -423,13 +453,104 @@ export class NotificationService { } } - public async testDispatch(type: NotificationChannelType, url: string, config?: unknown) { + public async testDispatch(type: NotificationChannelType, url: string, config?: unknown, template?: string | null) { if (!ALLOWED_CHANNEL_TYPES.has(type)) throw new Error(`Invalid notification type: ${type}`); const validation = validateNotificationChannel(type, url, config); if (validation) throw new Error(`URL ${validation}`); const stored = type === 'apprise' ? normalizeAppriseStoredJson(url, config) : (config == null ? null : JSON.stringify(config)); const retries = this.resolveDispatchRetries(); - await this.sendWithRetries(type, url, 'info', '🔌 Test Notification from Sencho!', stored, retries); + // Single timestamp for the whole test dispatch so every retry renders the same body. + await this.sendWithRetries(type, url, 'info', '🔌 Test Notification from Sencho!', stored, retries, { + category: 'system', + stackName: '', + actor: '', + timestampMs: Date.now(), + template: template ?? null, + }); + } + + /** + * Deliver a user-authored payload template for any channel type. The + * rendered document fully replaces the built-in body. Apprise keeps its + * destinations authoritative: the stored `urls` (stateless) or `tag` + * (keyed) are merged in after rendering, and the template may not carry + * those keys (rejected at write time). Render failures are non-retryable: + * a template that survived save-time validation cannot fail here, so a + * failure means hand-edited storage. + */ + private async sendTemplatedPayload( + type: string, + url: string, + level: 'info' | 'warning' | 'error', + message: string, + config: string | null | undefined, + options: NotificationDispatchOptions, + ): Promise { + let payload: unknown; + try { + payload = renderPayloadTemplate(options.template!, { + level, + message, + category: options.category ?? '', + timestamp: new Date(options.timestampMs).toISOString(), + stack_name: options.stackName ?? '', + actor: options.actor ?? '', + }); + } catch (error) { + console.error('[Notify] Failed to render payload template:', error); + throw new NotificationDeliveryError('Templated payload could not be rendered', null, false); + } + + let body = payload; + if (type === 'apprise') { + if (typeof body !== 'object' || body === null || Array.isArray(body)) { + throw new NotificationDeliveryError('Apprise payload template must render a JSON object', null, false); + } + const parsed = parseStoredAppriseConfig(url, config); + if (!parsed.ok) { + throw new NotificationDeliveryError('Stored Apprise configuration is invalid for this endpoint', null, false); + } + const merged: Record = { ...(body as Record) }; + if (parsed.mode === 'stateless') merged.urls = parsed.urlsJoined; + else if (parsed.tags) merged.tag = parsed.tags; + body = merged; + } + + let targetUrl = url; + let authorization: string | undefined; + if (type === 'ntfy') { + const normalized = this.normalizeNtfyEndpoint(url); + targetUrl = normalized.effectiveUrl; + authorization = normalized.authorization; + } + + try { + const headers: Record = { 'Content-Type': 'application/json' }; + if (authorization) headers['Authorization'] = authorization; + const response = await fetch(targetUrl, { + method: 'POST', + headers, + body: JSON.stringify(body), + signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS), + }); + if (type === 'apprise' && response.status === 204) { + throw new NotificationDeliveryError('Apprise returned no delivery (HTTP 204)', 204, false); + } + if (response.status >= 400 && response.status < 500) { + throw new NotificationDeliveryError(`${type} rejected templated payload with HTTP ${response.status}`, response.status, false); + } + if (!response.ok) { + throw new NotificationDeliveryError(`${type} responded with HTTP ${response.status}`, response.status, true); + } + } catch (error) { + if (error instanceof NotificationDeliveryError) throw error; + const aborted = error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError'); + throw new NotificationDeliveryError( + aborted ? `${type} request timed out` : `${type} request failed`, + null, + true, + ); + } } private async sendAppriseNotify( @@ -578,6 +699,30 @@ export class NotificationService { } } + /** + * Normalize an ntfy URL for delivery: strip URL userinfo into a Basic + * Authorization header (defensive; validateNtfyUrl rejects userinfo on + * the write path) and strip a trailing slash so URLs like + * https://ntfy.sh/mytopic/ reach the correct topic path. Returns the raw + * URL unchanged on parse failure. + */ + private normalizeNtfyEndpoint(url: string): { effectiveUrl: string; authorization?: string } { + try { + const parsed = new URL(url); + let authorization: string | undefined; + if (parsed.username || parsed.password) { + const encoded = btoa(`${decodeURIComponent(parsed.username)}:${decodeURIComponent(parsed.password)}`); + authorization = `Basic ${encoded}`; + parsed.username = ''; + parsed.password = ''; + } + parsed.pathname = parsed.pathname.replace(/\/+$/, ''); + return { effectiveUrl: parsed.toString(), authorization }; + } catch { + return { effectiveUrl: url }; + } + } + private async sendNtfy(url: string, level: 'info' | 'warning' | 'error', message: string) { const priorityMap = { info: 'default', @@ -594,21 +739,8 @@ export class NotificationService { }; if (tags) headers['Tags'] = tags; - // Normalize the URL: strip userinfo (defensive; validateNtfyUrl rejects it - // on the write path) and strip a trailing slash so that URLs like - // https://ntfy.sh/mytopic/ reach the correct topic path. - let effectiveUrl = url; - try { - const parsed = new URL(url); - if (parsed.username || parsed.password) { - const encoded = btoa(`${decodeURIComponent(parsed.username)}:${decodeURIComponent(parsed.password)}`); - headers['Authorization'] = `Basic ${encoded}`; - parsed.username = ''; - parsed.password = ''; - } - parsed.pathname = parsed.pathname.replace(/\/+$/, ''); - effectiveUrl = parsed.toString(); - } catch { /* use the raw url on parse failure */ } + const { effectiveUrl, authorization } = this.normalizeNtfyEndpoint(url); + if (authorization) headers['Authorization'] = authorization; try { const response = await fetch(effectiveUrl, { diff --git a/docs/features/alerts-notifications.mdx b/docs/features/alerts-notifications.mdx index 34689f79..9741fb4c 100644 --- a/docs/features/alerts-notifications.mdx +++ b/docs/features/alerts-notifications.mdx @@ -57,6 +57,55 @@ The **Test** button on each tab dispatches the literal message `🔌 Test Notifi Each delivery attempt is an HTTP POST with a 10-second `AbortSignal.timeout`. By default (`Delivery retries` = 0) Sencho makes one attempt. You can allow up to three extra in-process attempts with a fixed one-second delay between them. Retries apply only to classified transient failures (for example HTTP 5xx or network timeouts). Client errors such as HTTP 4xx and Apprise HTTP 204 are not retried. There is no durable retry queue: if the process exits mid-dispatch, remaining attempts are not persisted. Delivery is at-least-once under ambiguous timeouts or connection resets, so a receiver that accepted a request whose response was lost can receive a duplicate. If every attempt fails, the alert remains in the bell with `dispatch_error` set. +### Payload templates + +Each channel tab carries an **Edit Payload** toggle below the **Enabled** switch. Opening it reveals a JSON editor. When you save a template, it replaces Sencho's built-in body for that channel: every alert is posted as your JSON with the variables below substituted in. Leaving the editor blank restores the built-in payload. + +The following variables are available: + +| Variable | Content | +|----------|---------| +| `{{level}}` | `info`, `warning`, or `error` | +| `{{message}}` | The alert message text | +| `{{category}}` | The notification category (for example `deploy_failure` or `monitor_alert`) | +| `{{timestamp}}` | ISO-8601 timestamp of the alert | +| `{{stack_name}}` | The stack the alert concerns, empty when none | +| `{{actor}}` | The user who triggered the action, empty for automated alerts | + +Variables are replaced with JSON-escaped values, so quotes or newlines inside a value cannot break the document. A variable can stand alone as a whole string (`"{{message}}"`) or appear inside a string (`"status: {{level}}"`). A variable with no context (for example `{{stack_name}}` on a node-level alert) becomes an empty string. `container_name` is not available. + +Sencho validates the template on save: it must be valid JSON after variable substitution, it may only use the variables above, and it may be at most 8000 characters. Unknown variables and malformed JSON are rejected before anything is saved. + +Two channel-specific behaviors: + +- **ntfy**: with a template, Sencho publishes JSON instead of the usual plain-text message. The Title, Priority, and Tags headers are not sent. +- **Apprise**: destination URLs (stateless endpoints) and tags (keyed endpoints) stay managed by the channel fields. They are merged into your rendered body automatically, and a template cannot set `urls` or `tag` itself. The template must be a JSON object. + +The **Test** button uses the template currently in the editor, so you can validate a payload before saving it. Notification routes always use the built-in payload for their channel; templates apply to the global channels in this section. + +For example, a webhook receiver that wants a flat structure: + +```json +{ + "title": "{{level}}", + "body": "{{message}}", + "category": "{{category}}", + "timestamp": "{{timestamp}}", + "stack": "{{stack_name}}", + "actor": "{{actor}}" +} +``` + +An ntfy topic that publishes JSON: + +```json +{ + "topic": "sencho-alerts", + "message": "[{{level}}] {{message}}", + "tags": ["warning"] +} +``` + ## Notification Routing diff --git a/docs/reference/settings.mdx b/docs/reference/settings.mdx index 39c174ab..70ccdbd9 100644 --- a/docs/reference/settings.mdx +++ b/docs/reference/settings.mdx @@ -412,8 +412,9 @@ For each agent: | **Enabled** toggle | Activates or deactivates this agent. Disabled agents receive no messages even if a URL is saved. | | **Webhook URL** | The endpoint Sencho will POST to when an alert fires. | | **Apprise** | Use a keyed `/notify/` endpoint with optional tags, or a stateless `/notify` endpoint with destination URLs. Apprise accepts HTTP or HTTPS. | +| **Payload template** | Optional JSON body that replaces the built-in payload for this channel. Blank restores the built-in. Apprise destinations stay managed by the channel fields. See [Payload templates](/features/alerts-notifications#payload-templates). | -Click **Save** to persist changes. Click **Test** to send a test payload immediately and verify delivery. +Click **Save** to persist changes. Click **Test** to send a test payload immediately and verify delivery; the test uses the template currently in the editor when one is set. At least one agent must be enabled for stack alerts to deliver notifications. See [Alerts & Notifications](/features/alerts-notifications) for how to create alert rules. diff --git a/frontend/src/components/settings/NotificationsSection.tsx b/frontend/src/components/settings/NotificationsSection.tsx index 1979025c..dcd7e880 100644 --- a/frontend/src/components/settings/NotificationsSection.tsx +++ b/frontend/src/components/settings/NotificationsSection.tsx @@ -24,14 +24,25 @@ type ChannelType = 'discord' | 'slack' | 'webhook' | 'apprise' | 'ntfy'; function emptyAgents(): Record { return { - discord: { type: 'discord', url: '', enabled: false }, - slack: { type: 'slack', url: '', enabled: false }, - webhook: { type: 'webhook', url: '', enabled: false }, - apprise: { type: 'apprise', url: '', enabled: false, config: null }, - ntfy: { type: 'ntfy', url: '', enabled: false }, + discord: { type: 'discord', url: '', enabled: false, payload_template: null }, + slack: { type: 'slack', url: '', enabled: false, payload_template: null }, + webhook: { type: 'webhook', url: '', enabled: false, payload_template: null }, + apprise: { type: 'apprise', url: '', enabled: false, config: null, payload_template: null }, + ntfy: { type: 'ntfy', url: '', enabled: false, payload_template: null }, }; } +const EMPTY_TEMPLATE_STATE: Record = { + discord: false, + slack: false, + webhook: false, + apprise: false, + ntfy: false, +}; + +// Mirrors PAYLOAD_TEMPLATE_VARS in backend/src/helpers/notificationPayloadTemplate.ts. +const PAYLOAD_TEMPLATE_VARS = '{{level}} {{message}} {{category}} {{timestamp}} {{stack_name}} {{actor}}'; + function appriseWriteConfig(agent: Agent): { urls: string } | { tags: string } { if (isStatelessAppriseEndpoint(agent.url)) { return { urls: agent.config?.urls ?? '' }; @@ -70,6 +81,8 @@ export function NotificationsSection({ onDirtyChange }: NotificationsSectionProp const [isTestingAgent, setIsTestingAgent] = useState>({}); const [appriseUrlDirty, setAppriseUrlDirty] = useState(false); const [appriseConfigDirty, setAppriseConfigDirty] = useState(false); + const [payloadOpen, setPayloadOpen] = useState>(EMPTY_TEMPLATE_STATE); + const [templateDirty, setTemplateDirty] = useState>(EMPTY_TEMPLATE_STATE); const [retries, setRetries] = useState(DEFAULT_SETTINGS.notification_dispatch_retries!); const [savedRetries, setSavedRetries] = useState(DEFAULT_SETTINGS.notification_dispatch_retries!); @@ -105,6 +118,7 @@ export function NotificationsSection({ onDirtyChange }: NotificationsSectionProp setAgents(next); setAppriseUrlDirty(false); setAppriseConfigDirty(false); + setTemplateDirty(EMPTY_TEMPLATE_STATE); } catch (e) { console.error('Failed to fetch agents', e); } @@ -194,6 +208,8 @@ export function NotificationsSection({ onDirtyChange }: NotificationsSectionProp setAgents(emptyAgents()); setAppriseUrlDirty(false); setAppriseConfigDirty(false); + setPayloadOpen(EMPTY_TEMPLATE_STATE); + setTemplateDirty(EMPTY_TEMPLATE_STATE); setRetries(DEFAULT_SETTINGS.notification_dispatch_retries!); setSavedRetries(DEFAULT_SETTINGS.notification_dispatch_retries!); setRetriesLoadState('idle'); @@ -292,6 +308,12 @@ export function NotificationsSection({ onDirtyChange }: NotificationsSectionProp enabled: agent.enabled, }; + // Include the template only when the editor was touched so a clean + // save (including enable toggles) preserves the stored value. + if (templateDirty[type]) { + body.payload_template = agents[type].payload_template ?? ''; + } + if (type !== 'apprise') { body.url = agent.url; } else { @@ -351,6 +373,8 @@ export function NotificationsSection({ onDirtyChange }: NotificationsSectionProp if (type === 'apprise') { body.config = appriseWriteConfig(agents.apprise); } + // The test uses the current editor template; blank falls back to the built-in body. + body.payload_template = agents[type].payload_template ?? ''; const res = await apiFetch('/notifications/test', { method: 'POST', body: JSON.stringify(body), @@ -380,6 +404,40 @@ export function NotificationsSection({ onDirtyChange }: NotificationsSectionProp onChange={(c) => handleAgentChange(type, 'enabled', c)} /> + + + + {payloadOpen[type] && ( + +