feat(notifications): customizable per-channel JSON payload templates (#1805)

Add an optional Edit Payload editor to every notification channel
(Discord, Slack, Webhook, Apprise, ntfy). A saved template replaces the
built-in payload for that channel, with {{level}}, {{message}},
{{category}}, {{timestamp}}, {{stack_name}}, and {{actor}} substituted as
JSON-escaped values (variables may stand alone or be mixed into strings).
Templates are validated on save: known variables only, no unterminated
tokens, valid JSON after substitution, 8000 characters max. Apprise keeps
urls/tag managed by the channel fields and merges them server-side at
dispatch. ntfy publishes JSON instead of plain text when templated. Test
dispatch uses the editor template.
This commit is contained in:
Anso
2026-08-11 10:25:29 -04:00
committed by GitHub
parent 578ce7684d
commit 866d784316
16 changed files with 1508 additions and 33 deletions
+148
View File
@@ -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');
});
});
+110
View File
@@ -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');
});
});
@@ -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);
});
});
@@ -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}}"}');
});
});
@@ -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');
});
});
@@ -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<string, unknown> = {}) {
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<string, unknown> = {}) {
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<typeof vi.fn>;
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',
});
});
});
@@ -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<string, unknown>);
}
} 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<string, string | undefined>): 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');
}
}
+15
View File
@@ -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));
+8 -2
View File
@@ -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<void> => {
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)) });
+7 -4
View File
@@ -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);
}
}
+152 -20
View File
@@ -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<void> {
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<void> {
private async sendToChannel(type: string, url: string, level: 'info' | 'warning' | 'error', message: string, config?: string | null, options?: NotificationDispatchOptions): Promise<void> {
// 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<void> {
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<string, unknown> = { ...(body as Record<string, unknown>) };
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<string, string> = { '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, {