feat: add Apprise as a fourth notification channel (#1644)

* feat: add Apprise as a fourth notification channel

Support keyed and stateless Apprise endpoints with secret-safe public DTOs, fail-closed malformed config, and mode-specific Settings UI. Docs and screenshots updated for four-channel Channels and routing.

* fix: harden Apprise secrets at rest and preserve-on-write saves

Encrypt Apprise endpoint and config with CryptoService so a downgrade cannot leak via SELECT *. Align channel and routing saves so blank destination fields omit config on same-mode URL edits, enforce keyed notify IDs, and keep secrets_redacted truthful.

* fix: harden Apprise route type changes and mixed-version config UI

Require a raw channel_url when switching notification-route types so ciphertext cannot strand under Discord/Slack/webhook. Default missing remote apprise status, replace Channels state on node switch, and exercise the production config-column migrator.

* fix: tolerate stub fleet configuration payloads without agents

Normalize remote Apprise agent status only when notifications.agents is present so successful Pilot/stub fetches stay online instead of throwing into the offline catch path.

* fix: correct TypeScript in configuration normalize tests

* fix: ignore stale Channels agent bodies after node switch

Compare the active node after response JSON parsing so a slow body
cannot overwrite the newly selected node's channel state.

* fix: isolate corrupt Apprise crypto and keep keyed Tags visible

Decrypt failures on one Apprise row no longer 500 agent/route lists or
suppress sibling channel dispatch. Treat public /notify/<redacted> as keyed
so Tags remain editable after reload.
This commit is contained in:
Anso
2026-07-18 16:32:58 -04:00
committed by GitHub
parent 674220b9de
commit 83b3d932e5
42 changed files with 2916 additions and 136 deletions
+119
View File
@@ -124,3 +124,122 @@ describe('POST /api/agents', () => {
expect(Boolean(agents[0].enabled)).toBe(true);
});
});
describe('Apprise agents - redaction and preserve-on-write', () => {
const keyedUrl = 'http://apprise.local/notify/key-secret-value';
const serviceUrl = 'discord://webhook-id/webhook-token?token=query-secret';
it('GET redacts keyed endpoint and never returns destination secrets', async () => {
DatabaseService.getInstance().upsertAgent(1, {
type: 'apprise',
url: keyedUrl,
enabled: true,
config: JSON.stringify({ tags: 'ops' }),
});
const res = await request(app).get('/api/agents').set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body[0].url).toBe('http://apprise.local/notify/<redacted>');
expect(res.body[0].config).toMatchObject({ mode: 'keyed', tags: 'ops', has_urls: false });
expect(res.body[0].secrets_redacted).toBe(true);
expect(JSON.stringify(res.body)).not.toContain('key-secret-value');
});
it('GET for stateless mode exposes providers and url_count only', async () => {
DatabaseService.getInstance().upsertAgent(1, {
type: 'apprise',
url: 'http://apprise.local/notify',
enabled: true,
config: JSON.stringify({ urls: serviceUrl }),
});
const res = await request(app).get('/api/agents').set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body[0].url).toBe('http://apprise.local/notify');
expect(res.body[0].config).toMatchObject({
mode: 'stateless',
has_urls: true,
providers: ['discord'],
url_count: 1,
});
expect(JSON.stringify(res.body)).not.toContain('webhook-token');
expect(JSON.stringify(res.body)).not.toContain('query-secret');
});
it('rejects posting a redacted endpoint URL', async () => {
DatabaseService.getInstance().upsertAgent(1, {
type: 'apprise',
url: keyedUrl,
enabled: true,
config: JSON.stringify({ tags: 'ops' }),
});
const res = await request(app).post('/api/agents').set('Cookie', adminCookie).send({
type: 'apprise',
url: 'http://apprise.local/notify/<redacted>',
enabled: true,
config: { tags: 'ops' },
});
expect(res.status).toBe(400);
});
it('rejects public DTO config shapes on write', async () => {
const res = await request(app).post('/api/agents').set('Cookie', adminCookie).send({
type: 'apprise',
url: 'http://apprise.local/notify',
enabled: true,
config: { mode: 'stateless', has_urls: true, providers: ['discord'], url_count: 1 },
});
expect(res.status).toBe(400);
});
it('preserves stored secrets when url and config are omitted', async () => {
const db = DatabaseService.getInstance();
db.upsertAgent(1, {
type: 'apprise',
url: keyedUrl,
enabled: true,
config: JSON.stringify({ tags: 'ops' }),
});
const res = await request(app).post('/api/agents').set('Cookie', adminCookie).send({
type: 'apprise',
enabled: false,
});
expect(res.status).toBe(200);
const agents = db.getAgents(1);
expect(agents[0].url).toBe(keyedUrl);
expect(agents[0].config).toBe(JSON.stringify({ tags: 'ops' }));
expect(Boolean(agents[0].enabled)).toBe(false);
});
it('creates a keyed agent with no config and persists {}', async () => {
const res = await request(app).post('/api/agents').set('Cookie', adminCookie).send({
type: 'apprise',
url: 'http://apprise.local/notify/new-key',
enabled: true,
});
expect(res.status).toBe(200);
const agent = DatabaseService.getInstance().getAgents(1).find(a => a.type === 'apprise');
expect(agent?.url).toBe('http://apprise.local/notify/new-key');
expect(agent?.config).toBe('{}');
});
it('rejects preserve-on-write when stored Apprise config is malformed', async () => {
DatabaseService.getInstance().upsertAgent(1, {
type: 'apprise',
url: keyedUrl,
enabled: true,
config: '{not-json',
});
const res = await request(app).post('/api/agents').set('Cookie', adminCookie).send({
type: 'apprise',
enabled: false,
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid/i);
expect(JSON.stringify(res.body)).not.toContain('key-secret-value');
});
});
+74 -1
View File
@@ -242,7 +242,7 @@ describe('POST /api/notifications/test', () => {
.set('Cookie', authCookie)
.send({ type: 'telegram', url: 'https://example.com' });
expect(res.status).toBe(400);
expect(res.body.error).toContain('discord, slack, webhook');
expect(res.body.error).toContain('discord, slack, webhook, apprise');
});
it('rejects missing type with 400', async () => {
@@ -277,4 +277,77 @@ describe('POST /api/notifications/test', () => {
.send({ type: 'discord', url: 'https://' });
expect(res.status).toBe(400);
});
it('dispatches keyed Apprise test with exact payload', 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' } });
expect(res.status).toBe(200);
expect(fetchMock).toHaveBeenCalledWith(
'http://apprise.local/notify/test-key',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
title: 'Sencho Alert [INFO]',
body: '🔌 Test Notification from Sencho!',
type: 'info',
tag: 'ops',
}),
}),
);
} finally {
vi.unstubAllGlobals();
}
});
it('dispatches stateless Apprise test with urls and rejects scheme-less tokens', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
vi.stubGlobal('fetch', fetchMock);
try {
const bad = await request(app)
.post('/api/notifications/test')
.set('Cookie', authCookie)
.send({ type: 'apprise', url: 'http://apprise.local/notify', config: { urls: 'no-scheme' } });
expect(bad.status).toBe(400);
expect(fetchMock).not.toHaveBeenCalled();
const ok = await request(app)
.post('/api/notifications/test')
.set('Cookie', authCookie)
.send({ type: 'apprise', url: 'http://apprise.local/notify', config: { urls: 'discord://token' } });
expect(ok.status).toBe(200);
expect(fetchMock).toHaveBeenCalledWith(
'http://apprise.local/notify',
expect.objectContaining({
body: JSON.stringify({
title: 'Sencho Alert [INFO]',
body: '🔌 Test Notification from Sencho!',
type: 'info',
urls: 'discord://token',
}),
}),
);
} finally {
vi.unstubAllGlobals();
}
});
it('returns sanitized details on Apprise 204 without leaking secrets', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 204 }));
try {
const res = await request(app)
.post('/api/notifications/test')
.set('Cookie', authCookie)
.send({ type: 'apprise', url: 'http://apprise.local/notify/secret-key', config: {} });
expect(res.status).toBe(500);
expect(JSON.stringify(res.body)).toContain('HTTP 204');
expect(JSON.stringify(res.body)).not.toContain('secret-key');
} finally {
vi.unstubAllGlobals();
}
});
});
@@ -0,0 +1,207 @@
/**
* Downgrade-safety: Apprise url/config must be encrypted at rest so a
* pre-Apprise binary's SELECT * + unsanitized GET cannot return raw secrets.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
import { serializePublicAgent, serializePublicNotificationRoute } from '../helpers/notificationChannels';
let tmpDir: string;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
});
afterAll(() => cleanupTestDb(tmpDir));
beforeEach(() => {
const db = DatabaseService.getInstance().getDb();
db.prepare('DELETE FROM agents').run();
db.prepare('DELETE FROM notification_routes').run();
});
describe('Apprise secrets at rest (downgrade-safe)', () => {
it('stores keyed agent secrets as ciphertext; SELECT * does not leak the notify key', () => {
const db = DatabaseService.getInstance();
const keySecret = 'SuperSecretKey99';
db.upsertAgent(1, {
type: 'apprise',
url: `http://apprise.local/notify/${keySecret}`,
enabled: true,
config: '{}',
});
const raw = db.getDb().prepare('SELECT * FROM agents WHERE type = ?').get('apprise') as {
url: string;
config: string | null;
};
expect(raw.url).toMatch(/^enc:/);
expect(raw.url).not.toContain(keySecret);
expect(raw.url).not.toContain('apprise.local');
expect(raw.config).toMatch(/^enc:/);
const apprise = db.getAgents(1).find(a => a.type === 'apprise')!;
expect(apprise.url).toBe(`http://apprise.local/notify/${keySecret}`);
const pub = serializePublicAgent(apprise);
expect(pub.secrets_redacted).toBe(true);
expect(pub.url).toContain('<redacted>');
expect(JSON.stringify(pub)).not.toContain(keySecret);
});
it('stores stateless destination URLs as ciphertext; SELECT * does not leak credentials', () => {
const db = DatabaseService.getInstance();
const destSecret = 'mailto://user:smtp-password@example.com';
db.upsertAgent(1, {
type: 'apprise',
url: 'http://apprise.local/notify',
enabled: true,
config: JSON.stringify({ urls: destSecret }),
});
const raw = db.getDb().prepare('SELECT * FROM agents WHERE type = ?').get('apprise') as {
url: string;
config: string | null;
};
expect(raw.url).toMatch(/^enc:/);
expect(raw.config).toMatch(/^enc:/);
expect(raw.config).not.toContain(destSecret);
expect(raw.config).not.toContain('smtp-password');
const apprise = db.getAgents(1).find(a => a.type === 'apprise')!;
expect(apprise.url).toBe('http://apprise.local/notify');
expect(apprise.config).toBe(JSON.stringify({ urls: destSecret }));
const pub = serializePublicAgent(apprise);
expect(pub.secrets_redacted).toBe(true);
expect(JSON.stringify(pub)).not.toContain(destSecret);
});
it('encrypts Apprise route channel_url and config the same way', () => {
const db = DatabaseService.getInstance();
const keySecret = 'RouteKey_01';
const now = Date.now();
const route = db.createNotificationRoute({
name: 'apprise-route',
node_id: null,
stack_patterns: [],
label_ids: null,
categories: null,
channel_type: 'apprise',
channel_url: `http://apprise.local/notify/${keySecret}`,
config: '{}',
priority: 0,
enabled: true,
created_at: now,
updated_at: now,
});
const raw = db.getDb().prepare('SELECT * FROM notification_routes WHERE id = ?').get(route.id) as {
channel_url: string;
config: string | null;
};
expect(raw.channel_url).toMatch(/^enc:/);
expect(raw.channel_url).not.toContain(keySecret);
expect(raw.config).toMatch(/^enc:/);
const loaded = db.getNotificationRoute(route.id)!;
expect(loaded.channel_url).toBe(`http://apprise.local/notify/${keySecret}`);
const pub = serializePublicNotificationRoute(loaded);
expect(pub.secrets_redacted).toBe(true);
expect(pub.channel_url).toContain('<redacted>');
expect(JSON.stringify(pub)).not.toContain(keySecret);
});
it('does not encrypt Discord agent URLs (unchanged channel behavior)', () => {
const db = DatabaseService.getInstance();
const url = 'https://discord.com/api/webhooks/123/plaintext-token';
db.upsertAgent(1, { type: 'discord', url, enabled: true });
const raw = db.getDb().prepare('SELECT * FROM agents WHERE type = ?').get('discord') as { url: string };
expect(raw.url).toBe(url);
expect(raw.url.startsWith('enc:')).toBe(false);
const pub = serializePublicAgent(db.getAgents(1).find(a => a.type === 'discord')!);
expect(pub.secrets_redacted).toBe(false);
expect(pub.url).toBe(url);
});
it('isolates corrupt Apprise ciphertext so sibling channels still load and Apprise can be repaired', () => {
const db = DatabaseService.getInstance();
const discordUrl = 'https://discord.com/api/webhooks/123/plaintext-token';
db.upsertAgent(1, { type: 'discord', url: discordUrl, enabled: true });
db.upsertAgent(1, {
type: 'apprise',
url: 'http://apprise.local/notify/good-key',
enabled: true,
config: '{}',
});
// Simulate backup-restore / key-rotation damage: enc: prefix with invalid payload.
db.getDb().prepare('UPDATE agents SET url = ?, config = ? WHERE type = ?').run(
'enc:000000000000000000000000:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:bbbb',
'enc:000000000000000000000000:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:bbbb',
'apprise',
);
const agents = db.getAgents(1);
expect(agents.find(a => a.type === 'discord')!.url).toBe(discordUrl);
const broken = agents.find(a => a.type === 'apprise')!;
expect(broken.url).toBe('');
expect(broken.config).toBeNull();
const enabled = db.getEnabledAgents(1);
expect(enabled.some(a => a.type === 'discord')).toBe(true);
db.upsertAgent(1, {
type: 'apprise',
url: 'http://apprise.local/notify/repaired-key',
enabled: true,
config: '{}',
});
expect(db.getAgents(1).find(a => a.type === 'apprise')!.url).toBe(
'http://apprise.local/notify/repaired-key',
);
});
it('isolates corrupt Apprise route ciphertext from the routes list', () => {
const db = DatabaseService.getInstance();
const now = Date.now();
db.createNotificationRoute({
name: 'discord-ok',
node_id: null,
stack_patterns: [],
label_ids: null,
categories: null,
channel_type: 'discord',
channel_url: 'https://discord.com/api/webhooks/1/token',
config: null,
priority: 0,
enabled: true,
created_at: now,
updated_at: now,
});
const apprise = db.createNotificationRoute({
name: 'apprise-broken',
node_id: null,
stack_patterns: [],
label_ids: null,
categories: null,
channel_type: 'apprise',
channel_url: 'http://apprise.local/notify/route-key',
config: '{}',
priority: 1,
enabled: true,
created_at: now,
updated_at: now,
});
db.getDb().prepare('UPDATE notification_routes SET channel_url = ? WHERE id = ?').run(
'enc:000000000000000000000000:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:bbbb',
apprise.id,
);
const routes = db.getNotificationRoutes();
expect(routes.find(r => r.name === 'discord-ok')!.channel_url).toContain('discord.com');
expect(routes.find(r => r.name === 'apprise-broken')!.channel_url).toBe('');
});
});
@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest';
import { normalizeConfigurationAgents, normalizeRemoteConfigurationStatus } from '../helpers/configurationStatus';
describe('normalizeConfigurationAgents', () => {
it('defaults missing apprise to disabled/unconfigured', () => {
const normalized = normalizeConfigurationAgents({
discord: { configured: true, enabled: true },
slack: { configured: false, enabled: false },
webhook: { configured: true, enabled: false },
});
expect(normalized.apprise).toEqual({ configured: false, enabled: false });
expect(normalized.discord.enabled).toBe(true);
});
it('preserves an explicit apprise slot', () => {
const normalized = normalizeConfigurationAgents({
discord: { configured: false, enabled: false },
slack: { configured: false, enabled: false },
webhook: { configured: false, enabled: false },
apprise: { configured: true, enabled: true },
});
expect(normalized.apprise).toEqual({ configured: true, enabled: true });
});
});
describe('normalizeRemoteConfigurationStatus', () => {
it('passes through stub payloads without notifications.agents', () => {
const stub = { ssoConfigured: false, alertsConfigured: true };
expect(normalizeRemoteConfigurationStatus(stub)).toEqual(stub);
});
it('fills missing apprise when an agents block is present', () => {
const raw = {
notifications: {
agents: {
discord: { configured: true, enabled: true },
slack: { configured: false, enabled: false },
webhook: { configured: false, enabled: false },
},
},
};
const normalized = normalizeRemoteConfigurationStatus(raw);
expect(normalized.notifications.agents).toEqual({
discord: { configured: true, enabled: true },
slack: { configured: false, enabled: false },
webhook: { configured: false, enabled: false },
apprise: { configured: false, enabled: false },
});
});
});
@@ -54,7 +54,12 @@ describe('GET /api/dashboard/configuration', () => {
expect(res.body).toMatchObject({
tier: expect.any(String),
notifications: {
agents: { discord: { configured: expect.any(Boolean) }, slack: { configured: expect.any(Boolean) }, webhook: { configured: expect.any(Boolean) } },
agents: {
discord: { configured: expect.any(Boolean) },
slack: { configured: expect.any(Boolean) },
webhook: { configured: expect.any(Boolean) },
apprise: { configured: expect.any(Boolean) },
},
alertRules: expect.any(Number),
routingRules: { count: expect.any(Number), enabledCount: expect.any(Number), locked: expect.any(Boolean) },
},
@@ -108,4 +108,29 @@ describe('conditionalJsonParser remote-proxy bypass', () => {
expect(lastUpstreamAuth).toBeNull();
expect([200, 404]).toContain(res.status);
});
it('forwards Apprise agent config bodies intact to the remote', async () => {
lastUpstreamBody = null;
lastUpstreamAuth = null;
const payload = {
type: 'apprise',
url: 'http://apprise.local/notify',
enabled: true,
config: { urls: 'discord://webhook-id/webhook-token?token=query-secret' },
};
const res = await request(app)
.post('/api/agents')
.set('Authorization', authHeader)
.set('x-node-id', String(remoteNodeId))
.set('Content-Type', 'application/json')
.send(payload);
expect(res.status).toBe(200);
expect(lastUpstreamBody).not.toBeNull();
expect(JSON.parse(lastUpstreamBody!.toString('utf-8'))).toEqual(payload);
expect(lastUpstreamAuth).toBe('Bearer bypass-test-token');
expect(JSON.stringify(res.body)).not.toContain('query-secret');
});
});
@@ -0,0 +1,197 @@
/**
* Apprise delivery through NotificationService: payloads, status classes,
* fail-closed malformed config, and dispatch_error recording.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const {
mockGetEnabledNotificationRoutes,
mockGetEnabledNotificationSuppressionRules,
mockGetEnabledAgents,
mockGetStackLabelIds,
mockAddNotificationHistory,
mockUpdateNotificationDispatchError,
} = vi.hoisted(() => ({
mockGetEnabledNotificationRoutes: vi.fn().mockReturnValue([]),
mockGetEnabledNotificationSuppressionRules: vi.fn().mockReturnValue([]),
mockGetEnabledAgents: vi.fn().mockReturnValue([]),
mockGetStackLabelIds: vi.fn().mockReturnValue([]),
mockAddNotificationHistory: vi.fn().mockReturnValue({
id: 99,
level: 'info',
message: 'test',
timestamp: Date.now(),
is_read: 0,
}),
mockUpdateNotificationDispatchError: vi.fn(),
}));
vi.mock('../services/DatabaseService', () => ({
DatabaseService: {
getInstance: () => ({
getEnabledNotificationRoutes: mockGetEnabledNotificationRoutes,
getEnabledNotificationSuppressionRules: mockGetEnabledNotificationSuppressionRules,
getEnabledAgents: mockGetEnabledAgents,
getStackLabelIds: mockGetStackLabelIds,
addNotificationHistory: mockAddNotificationHistory,
updateNotificationDispatchError: mockUpdateNotificationDispatchError,
}),
},
}));
vi.mock('../services/NodeRegistry', () => ({
NodeRegistry: {
getInstance: () => ({
getDefaultNodeId: () => 1,
getComposeDir: () => '/app/compose',
}),
},
}));
vi.mock('../services/StackActivityMetricsService', () => ({
StackActivityMetricsService: {
getInstance: () => ({
record: vi.fn(),
}),
},
}));
import { NotificationService, NotificationDeliveryError } from '../services/NotificationService';
const KEYED = 'http://apprise.local/notify/key-secret';
const STATELESS = 'http://apprise.local/notify';
function makeAppriseRoute(overrides: Record<string, unknown> = {}) {
return {
id: 1,
name: 'Apprise route',
node_id: null,
stack_patterns: ['my-app'],
label_ids: null,
categories: null,
channel_type: 'apprise' as const,
channel_url: KEYED,
config: '{}',
priority: 0,
enabled: true,
created_at: 1,
updated_at: 1,
...overrides,
};
}
describe('NotificationService - Apprise delivery', () => {
let svc: NotificationService;
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
(NotificationService as unknown as { instance?: NotificationService }).instance = undefined;
svc = NotificationService.getInstance();
mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
vi.stubGlobal('fetch', mockFetch);
mockGetEnabledNotificationRoutes.mockReturnValue([]);
mockGetEnabledNotificationSuppressionRules.mockReturnValue([]);
mockGetEnabledAgents.mockReturnValue([]);
mockUpdateNotificationDispatchError.mockClear();
mockAddNotificationHistory.mockClear();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it('sends keyed payload without tag when tags are empty', async () => {
mockGetEnabledNotificationRoutes.mockReturnValue([makeAppriseRoute({ config: '{}' })]);
await svc.dispatchAlert('warning', 'deploy_failure', 'boom', { stackName: 'my-app' });
expect(mockFetch).toHaveBeenCalledWith(KEYED, expect.objectContaining({
method: 'POST',
body: JSON.stringify({
title: 'Sencho Alert [WARNING]',
body: 'boom',
type: 'warning',
}),
}));
});
it('sends keyed tag and maps error to failure', async () => {
mockGetEnabledNotificationRoutes.mockReturnValue([
makeAppriseRoute({ config: JSON.stringify({ tags: 'ops' }) }),
]);
await svc.dispatchAlert('error', 'deploy_failure', 'boom', { stackName: 'my-app' });
expect(mockFetch).toHaveBeenCalledWith(KEYED, expect.objectContaining({
body: JSON.stringify({
title: 'Sencho Alert [ERROR]',
body: 'boom',
type: 'failure',
tag: 'ops',
}),
}));
});
it('sends stateless urls from stored config via agent fallback', async () => {
mockGetEnabledAgents.mockReturnValue([{
type: 'apprise',
url: STATELESS,
enabled: true,
config: JSON.stringify({ urls: 'discord://token mailto://a@b.com' }),
}]);
await svc.dispatchAlert('info', 'deploy_success', 'ok');
expect(mockFetch).toHaveBeenCalledWith(STATELESS, expect.objectContaining({
body: JSON.stringify({
title: 'Sencho Alert [INFO]',
body: 'ok',
type: 'info',
urls: 'discord://token mailto://a@b.com',
}),
}));
});
it('treats HTTP 204 as non-retryable failure and records dispatch_error', async () => {
mockFetch.mockResolvedValue({ ok: true, status: 204 });
mockGetEnabledNotificationRoutes.mockReturnValue([makeAppriseRoute()]);
await svc.dispatchAlert('info', 'deploy_success', 'ok', { stackName: 'my-app' });
expect(mockUpdateNotificationDispatchError).toHaveBeenCalledWith(
99,
expect.stringContaining('HTTP 204'),
);
const errText = String(mockUpdateNotificationDispatchError.mock.calls[0][1]);
expect(errText).not.toContain('key-secret');
});
it('classifies 4xx as non-retryable and 5xx as retryable via testDispatch', async () => {
mockFetch.mockResolvedValueOnce({ ok: false, status: 400 });
await expect(svc.testDispatch('apprise', KEYED, {})).rejects.toMatchObject({
message: expect.stringContaining('HTTP 400'),
retryable: false,
} satisfies Partial<NotificationDeliveryError>);
mockFetch.mockResolvedValueOnce({ ok: false, status: 503 });
await expect(svc.testDispatch('apprise', KEYED, {})).rejects.toMatchObject({
message: expect.stringContaining('HTTP 503'),
retryable: true,
});
});
it('wraps network failures as retryable sanitized errors', async () => {
mockFetch.mockRejectedValueOnce(new TypeError('fetch failed'));
await expect(svc.testDispatch('apprise', KEYED, {})).rejects.toMatchObject({
message: 'Apprise request failed',
retryable: true,
status: null,
});
});
it('does not fetch when stored config is malformed and records a sanitized error', async () => {
mockGetEnabledNotificationRoutes.mockReturnValue([
makeAppriseRoute({ config: '{broken', channel_url: KEYED }),
]);
await svc.dispatchAlert('info', 'deploy_success', 'ok', { stackName: 'my-app' });
expect(mockFetch).not.toHaveBeenCalled();
expect(mockUpdateNotificationDispatchError).toHaveBeenCalledWith(
99,
expect.stringContaining('Apprise configuration is missing or invalid'),
);
expect(String(mockUpdateNotificationDispatchError.mock.calls[0][1])).not.toContain('key-secret');
});
});
@@ -0,0 +1,108 @@
/**
* Additive `config` columns on agents and notification_routes.
* Exercises production DatabaseService startup against a pre-config schema.
*/
import { describe, it, expect, afterEach } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import Database from 'better-sqlite3';
import { DatabaseService } from '../services/DatabaseService';
function resetDatabaseSingleton(): void {
const holder = DatabaseService as unknown as { instance?: DatabaseService };
const existing = holder.instance;
if (existing) {
try {
existing.getDb().close();
} catch {
// already closed
}
holder.instance = undefined;
}
}
describe('notification channel config column migration', () => {
let scratchDir: string | null = null;
afterEach(() => {
resetDatabaseSingleton();
if (scratchDir) {
try {
fs.rmSync(scratchDir, { recursive: true, force: true });
} catch {
// best-effort
}
scratchDir = null;
}
});
it('adds config columns via DatabaseService startup and preserves legacy row values', () => {
scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-apprise-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
);
INSERT INTO agents (node_id, type, url, enabled)
VALUES (1, 'discord', 'https://discord.example/webhook/legacy', 1);
CREATE TABLE notification_routes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
channel_type TEXT NOT NULL,
channel_url TEXT NOT NULL,
stack_patterns TEXT NOT NULL DEFAULT '[]',
priority INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
INSERT INTO notification_routes
(name, channel_type, channel_url, stack_patterns, priority, enabled, created_at, updated_at)
VALUES ('Legacy', 'slack', 'https://hooks.slack.com/services/legacy', '[]', 0, 1, 1, 1);
`);
} 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 }>;
const routeCols = db.getDb().prepare('PRAGMA table_info(notification_routes)').all() as Array<{ name: string }>;
expect(agentCols.filter(c => c.name === 'config')).toHaveLength(1);
expect(routeCols.filter(c => c.name === 'config')).toHaveLength(1);
const agent = db.getDb().prepare('SELECT type, url, config FROM agents WHERE type = ?').get('discord') as {
type: string; url: string; config: string | null;
};
expect(agent.url).toBe('https://discord.example/webhook/legacy');
expect(agent.config).toBeNull();
const route = db.getDb().prepare('SELECT name, channel_url, config FROM notification_routes WHERE name = ?').get('Legacy') as {
name: string; channel_url: string; config: string | null;
};
expect(route.channel_url).toBe('https://hooks.slack.com/services/legacy');
expect(route.config).toBeNull();
// Idempotent reopen: columns stay singular and values stay intact.
resetDatabaseSingleton();
process.env.DATA_DIR = scratchDir;
const db2 = DatabaseService.getInstance();
const agentCols2 = db2.getDb().prepare('PRAGMA table_info(agents)').all() as Array<{ name: string }>;
expect(agentCols2.filter(c => c.name === 'config')).toHaveLength(1);
const agent2 = db2.getDb().prepare('SELECT url, config FROM agents WHERE type = ?').get('discord') as {
url: string; config: string | null;
};
expect(agent2.url).toBe('https://discord.example/webhook/legacy');
expect(agent2.config).toBeNull();
});
});
@@ -1,9 +1,17 @@
/**
* Unit tests for notification channel helpers. Focused on maskWebhookUrl,
* which must never let a channel's embedded auth token reach a log line.
* Unit tests for notification channel helpers: masking, Apprise validation,
* fail-closed stored parsing, and public DTO redaction.
*/
import { describe, it, expect } from 'vitest';
import { maskWebhookUrl } from '../helpers/notificationChannels';
import {
classifyAppriseConfig,
maskWebhookUrl,
normalizeAppriseStoredJson,
parseStoredAppriseConfig,
serializePublicAgent,
serializePublicNotificationRoute,
validateNotificationChannel,
} from '../helpers/notificationChannels';
describe('maskWebhookUrl', () => {
it('redacts the token-bearing path of a Discord webhook URL', () => {
@@ -47,3 +55,172 @@ describe('maskWebhookUrl', () => {
expect(maskWebhookUrl('not a url')).toBe('<invalid url>');
});
});
describe('Apprise channel helpers', () => {
it('accepts empty keyed configuration (missing, null, or {})', () => {
const endpoint = 'http://apprise.local/notify/key';
expect(validateNotificationChannel('apprise', endpoint)).toBeNull();
expect(validateNotificationChannel('apprise', endpoint, null)).toBeNull();
expect(validateNotificationChannel('apprise', endpoint, {})).toBeNull();
expect(classifyAppriseConfig(endpoint, null)).toEqual({ mode: 'keyed' });
expect(normalizeAppriseStoredJson(endpoint, null)).toBe('{}');
expect(normalizeAppriseStoredJson(endpoint, { tags: '' })).toBe('{}');
expect(normalizeAppriseStoredJson(endpoint, { tags: 'ops' })).toBe(JSON.stringify({ tags: 'ops' }));
});
it('validates and classifies keyed and stateless configurations', () => {
expect(validateNotificationChannel('apprise', 'http://apprise.local/notify/key', { tags: 'ops' })).toBeNull();
expect(classifyAppriseConfig('http://apprise.local/notify', { urls: 'discord://token slack://token' })).toEqual({
mode: 'stateless',
urls: ['discord://token', 'slack://token'],
});
expect(validateNotificationChannel('apprise', 'https://apprise.local/notify/key', { urls: 'discord://token' }))
.toMatch(/urls|unknown/);
});
it('rejects unknown config keys and scheme-less destination tokens', () => {
expect(validateNotificationChannel('apprise', 'http://apprise.local/notify/key', { tags: 'ops', extra: 1 }))
.toMatch(/unknown Apprise config field/);
expect(validateNotificationChannel('apprise', 'http://apprise.local/notify', { urls: 'discord://ok', mode: 'stateless' }))
.toMatch(/public summary|unknown/);
expect(validateNotificationChannel('apprise', 'http://apprise.local/notify', { urls: 'not-a-scheme-token' }))
.toMatch(/URI scheme/);
expect(validateNotificationChannel('apprise', 'http://apprise.local/notify', { urls: 'discord://ok', tags: 'x' }))
.toMatch(/tags/);
});
it('redacts Apprise endpoint keys and service URLs from public agents', () => {
const secret = 'discord://webhook-id/webhook-token?secret=query';
const agent = serializePublicAgent({
id: 1,
type: 'apprise',
url: 'https://apprise.local/notify/key-secret',
enabled: true,
config: JSON.stringify({ tags: 'ops' }),
});
expect(agent).toMatchObject({
type: 'apprise',
url: 'https://apprise.local/notify/<redacted>',
config: { mode: 'keyed', tags: 'ops', has_urls: false },
secrets_redacted: true,
});
expect(JSON.stringify(agent)).not.toContain(secret);
});
it('never leaks Apprise secrets from userinfo, host, path, or query in public DTOs', () => {
const secrets = [
'userinfo-secret',
'host-token.example',
'path-secret',
'query-secret',
];
const urls = [
'discord://userinfo-secret@host/path-secret?token=query-secret',
'feishu://host-token.example/path-secret',
'jira://APIKey:userinfo-secret@host/path-secret?token=query-secret',
].join(' ');
const agent = serializePublicAgent({
type: 'apprise',
url: 'http://apprise.local/notify',
enabled: true,
config: JSON.stringify({ urls }),
});
const route = serializePublicNotificationRoute({
id: 1,
name: 'r',
node_id: null,
stack_patterns: [],
label_ids: null,
categories: null,
channel_type: 'apprise',
channel_url: 'http://apprise.local/notify/path-secret',
config: JSON.stringify({ tags: 'ops' }),
priority: 0,
enabled: true,
created_at: 1,
updated_at: 1,
});
const blob = JSON.stringify({ agent, route });
for (const secret of secrets) {
expect(blob).not.toContain(secret);
}
expect(agent.config).toMatchObject({
mode: 'stateless',
has_urls: true,
providers: expect.arrayContaining(['discord', 'feishu', 'jira']),
url_count: 3,
});
expect(route.channel_url).toBe('http://apprise.local/notify/<redacted>');
});
it('rejects public DTO config shapes on validate', () => {
expect(validateNotificationChannel('apprise', 'http://apprise.local/notify', {
mode: 'stateless',
has_urls: true,
providers: ['discord'],
url_count: 1,
})).toMatch(/raw Apprise config/);
});
it('fail-closes malformed stored JSON and never exposes raw tokens as providers', () => {
const keyed = parseStoredAppriseConfig('http://apprise.local/notify/key', '{not-json');
expect(keyed).toEqual({ ok: false, reason: 'Apprise configuration is missing or invalid' });
const badToken = parseStoredAppriseConfig(
'http://apprise.local/notify',
JSON.stringify({ urls: 'super-secret-token' }),
);
expect(badToken.ok).toBe(false);
const agent = serializePublicAgent({
type: 'apprise',
url: 'http://apprise.local/notify',
enabled: true,
config: JSON.stringify({ urls: 'super-secret-token' }),
});
expect(agent.config).toBeNull();
expect(JSON.stringify(agent)).not.toContain('super-secret-token');
});
it('rejects Apprise notify keys outside the official alphanumeric/underscore/dash charset', () => {
expect(validateNotificationChannel('apprise', 'http://apprise.local/notify/bad.key')).toMatch(/notify key/);
expect(validateNotificationChannel('apprise', 'http://apprise.local/notify/has%20space')).toMatch(/notify key|valid configuration/);
expect(validateNotificationChannel('apprise', 'http://apprise.local/notify/bad!key')).toMatch(/notify key/);
expect(validateNotificationChannel('apprise', `http://apprise.local/notify/${'a'.repeat(129)}`)).toMatch(/notify key/);
expect(validateNotificationChannel('apprise', 'http://apprise.local/notify/a')).toBeNull();
expect(validateNotificationChannel('apprise', `http://apprise.local/notify/${'A1_-'}${'b'.repeat(124)}`)).toBeNull();
});
it('sets secrets_redacted only for Apprise public DTOs', () => {
expect(serializePublicAgent({
type: 'discord',
url: 'https://discord.com/api/webhooks/1/token',
enabled: true,
}).secrets_redacted).toBe(false);
expect(serializePublicAgent({
type: 'slack',
url: 'https://hooks.slack.com/services/a/b/c',
enabled: true,
}).secrets_redacted).toBe(false);
expect(serializePublicAgent({
type: 'webhook',
url: 'https://example.com/hook',
enabled: true,
}).secrets_redacted).toBe(false);
expect(serializePublicAgent({
type: 'apprise',
url: 'http://apprise.local/notify/k',
enabled: true,
config: '{}',
}).secrets_redacted).toBe(true);
});
it('treats null/empty stored config as valid empty keyed', () => {
expect(parseStoredAppriseConfig('http://apprise.local/notify/key', null)).toEqual({ ok: true, mode: 'keyed' });
expect(parseStoredAppriseConfig('http://apprise.local/notify/key', '{}')).toEqual({ ok: true, mode: 'keyed' });
expect(parseStoredAppriseConfig('http://apprise.local/notify', null).ok).toBe(false);
});
});
@@ -170,7 +170,7 @@ describe('POST /api/agents - validation', () => {
.set('Cookie', authCookie)
.send({ type: 'telegram', url: 'https://example.com/hook', enabled: true });
expect(res.status).toBe(400);
expect(res.body.error).toContain('discord, slack, webhook');
expect(res.body.error).toContain('discord, slack, webhook, apprise');
});
it('rejects non-HTTPS url', async () => {
@@ -346,7 +346,7 @@ describe('POST /api/notification-routes - validation', () => {
.set('Cookie', authCookie)
.send({ name: 'test', stack_patterns: ['app'], channel_type: 'telegram', channel_url: 'https://example.com' });
expect(res.status).toBe(400);
expect(res.body.error).toContain('discord, slack, webhook');
expect(res.body.error).toContain('discord, slack, webhook, apprise');
});
it('rejects non-HTTPS channel_url', async () => {
@@ -464,6 +464,282 @@ describe('POST /api/notification-routes/:id/test', () => {
});
});
describe('Apprise notification routes - redaction and preserve-on-write', () => {
const keyedUrl = 'http://apprise.local/notify/route-key-secret';
const serviceUrl = 'mailto://user:pass@smtp.example.com?to=ops@example.com';
let routeId: number;
beforeAll(async () => {
const res = await request(app)
.post('/api/notification-routes')
.set('Cookie', authCookie)
.send({
name: 'Apprise route',
stack_patterns: ['app'],
channel_type: 'apprise',
channel_url: keyedUrl,
config: { tags: 'ops' },
enabled: true,
});
expect(res.status).toBe(201);
routeId = res.body.id;
});
it('POST 201 and GET redact the keyed endpoint path', async () => {
const created = await request(app)
.post('/api/notification-routes')
.set('Cookie', authCookie)
.send({
name: 'Apprise create redact',
stack_patterns: [],
channel_type: 'apprise',
channel_url: keyedUrl,
config: { tags: 'night' },
});
expect(created.status).toBe(201);
expect(created.body.channel_url).toBe('http://apprise.local/notify/<redacted>');
expect(created.body.config).toMatchObject({ mode: 'keyed', tags: 'night', has_urls: false });
expect(JSON.stringify(created.body)).not.toContain('route-key-secret');
const listed = await request(app).get('/api/notification-routes').set('Cookie', authCookie);
const row = listed.body.find((r: { id: number }) => r.id === created.body.id);
expect(row.channel_url).toBe('http://apprise.local/notify/<redacted>');
expect(JSON.stringify(row)).not.toContain('route-key-secret');
await request(app).delete(`/api/notification-routes/${created.body.id}`).set('Cookie', authCookie);
});
it('POST 201 for stateless mode returns providers and url_count only', async () => {
const res = await request(app)
.post('/api/notification-routes')
.set('Cookie', authCookie)
.send({
name: 'Apprise stateless',
stack_patterns: [],
channel_type: 'apprise',
channel_url: 'http://apprise.local/notify',
config: { urls: serviceUrl },
});
expect(res.status).toBe(201);
expect(res.body.channel_url).toBe('http://apprise.local/notify');
expect(res.body.config).toMatchObject({
mode: 'stateless',
has_urls: true,
providers: ['mailto'],
url_count: 1,
});
expect(JSON.stringify(res.body)).not.toContain('pass@');
expect(JSON.stringify(res.body)).not.toContain('ops@example.com');
const stored = DatabaseService.getInstance().getNotificationRoute(res.body.id);
expect(stored?.channel_url).toBe('http://apprise.local/notify');
expect(stored?.config).toContain(serviceUrl);
await request(app).delete(`/api/notification-routes/${res.body.id}`).set('Cookie', authCookie);
});
it('PUT preserves secrets when channel_url and config are omitted', async () => {
const res = await request(app)
.put(`/api/notification-routes/${routeId}`)
.set('Cookie', authCookie)
.send({ name: 'Apprise route renamed', enabled: false });
expect(res.status).toBe(200);
expect(res.body.name).toBe('Apprise route renamed');
expect(res.body.channel_url).toBe('http://apprise.local/notify/<redacted>');
expect(JSON.stringify(res.body)).not.toContain('route-key-secret');
const stored = DatabaseService.getInstance().getNotificationRoute(routeId);
expect(stored?.channel_url).toBe(keyedUrl);
expect(stored?.config).toBe(JSON.stringify({ tags: 'ops' }));
});
it('PUT rejects channel_type change without a new channel_url and leaves the row unchanged', async () => {
const before = DatabaseService.getInstance().getDb()
.prepare('SELECT channel_type, channel_url, config FROM notification_routes WHERE id = ?')
.get(routeId) as { channel_type: string; channel_url: string; config: string | null };
expect(before.channel_type).toBe('apprise');
expect(before.channel_url.startsWith('enc:')).toBe(true);
const res = await request(app)
.put(`/api/notification-routes/${routeId}`)
.set('Cookie', authCookie)
.send({ channel_type: 'discord' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/channel_url is required when changing channel_type/i);
const after = DatabaseService.getInstance().getDb()
.prepare('SELECT channel_type, channel_url, config FROM notification_routes WHERE id = ?')
.get(routeId) as { channel_type: string; channel_url: string; config: string | null };
expect(after).toEqual(before);
});
it('PUT Apprise-to-Discord with a raw URL stores plaintext Discord credentials', async () => {
const created = await request(app)
.post('/api/notification-routes')
.set('Cookie', authCookie)
.send({
name: 'Apprise leave',
stack_patterns: [],
channel_type: 'apprise',
channel_url: 'http://apprise.local/notify/leave-key',
config: { tags: 'ops' },
});
expect(created.status).toBe(201);
const id = created.body.id as number;
const discordUrl = 'https://discord.com/api/webhooks/1/token-secret';
const res = await request(app)
.put(`/api/notification-routes/${id}`)
.set('Cookie', authCookie)
.send({ channel_type: 'discord', channel_url: discordUrl });
expect(res.status).toBe(200);
expect(res.body.channel_type).toBe('discord');
const raw = DatabaseService.getInstance().getDb()
.prepare('SELECT channel_type, channel_url, config FROM notification_routes WHERE id = ?')
.get(id) as { channel_type: string; channel_url: string; config: string | null };
expect(raw.channel_type).toBe('discord');
expect(raw.channel_url).toBe(discordUrl);
expect(raw.channel_url.startsWith('enc:')).toBe(false);
expect(raw.config).toBeNull();
await request(app).delete(`/api/notification-routes/${id}`).set('Cookie', authCookie);
});
it('PUT webhook-to-Apprise seals both channel_url and config as enc: ciphertext', async () => {
const notifyUrl = 'https://apprise.example/notify/promote-key';
const created = await request(app)
.post('/api/notification-routes')
.set('Cookie', authCookie)
.send({
name: 'Webhook promote',
stack_patterns: [],
channel_type: 'webhook',
channel_url: notifyUrl,
});
expect(created.status).toBe(201);
const id = created.body.id as number;
const before = DatabaseService.getInstance().getDb()
.prepare('SELECT channel_url FROM notification_routes WHERE id = ?')
.get(id) as { channel_url: string };
expect(before.channel_url).toBe(notifyUrl);
const res = await request(app)
.put(`/api/notification-routes/${id}`)
.set('Cookie', authCookie)
.send({
channel_type: 'apprise',
channel_url: notifyUrl,
config: { tags: 'fleet' },
});
expect(res.status).toBe(200);
expect(res.body.channel_type).toBe('apprise');
expect(res.body.channel_url).toBe('https://apprise.example/notify/<redacted>');
const raw = DatabaseService.getInstance().getDb()
.prepare('SELECT channel_type, channel_url, config FROM notification_routes WHERE id = ?')
.get(id) as { channel_type: string; channel_url: string; config: string | null };
expect(raw.channel_type).toBe('apprise');
expect(raw.channel_url.startsWith('enc:')).toBe(true);
expect(raw.channel_url).not.toContain('promote-key');
expect(raw.config?.startsWith('enc:')).toBe(true);
expect(raw.config).not.toContain('fleet');
const stored = DatabaseService.getInstance().getNotificationRoute(id);
expect(stored?.channel_url).toBe(notifyUrl);
expect(stored?.config).toBe(JSON.stringify({ tags: 'fleet' }));
await request(app).delete(`/api/notification-routes/${id}`).set('Cookie', authCookie);
});
it('PUT rejects a redacted channel_url', async () => {
const res = await request(app)
.put(`/api/notification-routes/${routeId}`)
.set('Cookie', authCookie)
.send({ channel_url: 'http://apprise.local/notify/<redacted>' });
expect(res.status).toBe(400);
});
it('PUT rejects public DTO config shapes', async () => {
const res = await request(app)
.put(`/api/notification-routes/${routeId}`)
.set('Cookie', authCookie)
.send({
channel_url: keyedUrl,
config: { mode: 'keyed', has_urls: false, providers: [] },
});
expect(res.status).toBe(400);
});
it('stored-route test uses raw config and returns sanitized 204 errors', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
vi.stubGlobal('fetch', fetchMock);
try {
const ok = await request(app)
.post(`/api/notification-routes/${routeId}/test`)
.set('Cookie', authCookie);
expect(ok.status).toBe(200);
expect(fetchMock).toHaveBeenCalledWith(
keyedUrl,
expect.objectContaining({
body: JSON.stringify({
title: 'Sencho Alert [INFO]',
body: '🔌 Test Notification from Sencho!',
type: 'info',
tag: 'ops',
}),
}),
);
} finally {
vi.unstubAllGlobals();
}
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 204 }));
try {
const fail = await request(app)
.post(`/api/notification-routes/${routeId}/test`)
.set('Cookie', authCookie);
expect(fail.status).toBe(500);
expect(JSON.stringify(fail.body)).toContain('HTTP 204');
expect(JSON.stringify(fail.body)).not.toContain('route-key-secret');
} finally {
vi.unstubAllGlobals();
}
});
it('stored-route test fails closed on malformed config without fetch', async () => {
const broken = await request(app)
.post('/api/notification-routes')
.set('Cookie', authCookie)
.send({
name: 'Broken Apprise',
stack_patterns: [],
channel_type: 'apprise',
channel_url: 'http://apprise.local/notify/broken-key',
config: { tags: 'x' },
});
expect(broken.status).toBe(201);
const id = broken.body.id as number;
DatabaseService.getInstance().updateNotificationRoute(id, { config: '{not-json' });
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
try {
const res = await request(app)
.post(`/api/notification-routes/${id}/test`)
.set('Cookie', authCookie);
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid/i);
expect(fetchMock).not.toHaveBeenCalled();
expect(JSON.stringify(res.body)).not.toContain('broken-key');
} finally {
vi.unstubAllGlobals();
await request(app).delete(`/api/notification-routes/${id}`).set('Cookie', authCookie);
}
});
});
// --- DELETE /api/notifications/:id NaN guard ---
describe('DELETE /api/notifications/:id - validation', () => {
@@ -0,0 +1,47 @@
/** Agent slot on dashboard/fleet configuration responses. */
export interface AgentStatus {
configured: boolean;
enabled: boolean;
}
export type ConfigurationAgents = {
discord: AgentStatus;
slack: AgentStatus;
webhook: AgentStatus;
apprise: AgentStatus;
};
/**
* Older remotes omit `apprise`. Default the slot so mixed-version hubs do not
* treat the response as malformed or crash UI consumers.
*/
export function normalizeConfigurationAgents(agents: {
discord: AgentStatus;
slack: AgentStatus;
webhook: AgentStatus;
apprise?: AgentStatus;
}): ConfigurationAgents {
return {
discord: agents.discord,
slack: agents.slack,
webhook: agents.webhook,
apprise: agents.apprise ?? { configured: false, enabled: false },
};
}
/**
* Normalize a remote `/dashboard/configuration` body when it carries an agents
* block. Stub or partial JSON without `notifications.agents` is returned as-is
* so a successful fetch still marks the node online.
*/
export function normalizeRemoteConfigurationStatus<T extends object>(raw: T): T {
const notifications = (raw as { notifications?: { agents?: Parameters<typeof normalizeConfigurationAgents>[0] } }).notifications;
if (!notifications?.agents) return raw;
return {
...raw,
notifications: {
...notifications,
agents: normalizeConfigurationAgents(notifications.agents),
},
};
}
+303 -3
View File
@@ -1,6 +1,54 @@
export const NOTIFICATION_CHANNEL_TYPES = ['discord', 'slack', 'webhook'] as const;
import type { Agent, NotificationRoute } from '../services/DatabaseService';
export const NOTIFICATION_CHANNEL_TYPES = ['discord', 'slack', 'webhook', 'apprise'] as const;
export type NotificationChannelType = typeof NOTIFICATION_CHANNEL_TYPES[number];
/** Write payload for Apprise agents/routes (never public DTO fields). */
export type AppriseWriteConfig = { tags?: string } | { urls: string };
/** Persisted JSON shape after normalize (keyed empty is `{}`). */
export type AppriseStoredConfig = { tags?: string } | { urls: string };
export type PublicAppriseConfig = {
mode: 'keyed' | 'stateless';
tags?: string;
has_urls: boolean;
providers?: string[];
url_count?: number;
};
export type PublicAgent = Omit<Agent, 'config' | 'type'> & {
type: NotificationChannelType;
config: PublicAppriseConfig | null;
/** True only when Apprise masking was applied; Discord/Slack/webhook still return raw URLs. */
secrets_redacted: boolean;
};
export type PublicNotificationRoute = Omit<NotificationRoute, 'config' | 'channel_type'> & {
channel_type: NotificationChannelType;
config: PublicAppriseConfig | null;
secrets_redacted: boolean;
};
/** Apprise notify key: 1128 alphanumeric, underscore, or dash (official API notes). */
export const APPRISE_NOTIFY_KEY = /^[A-Za-z0-9_-]{1,128}$/;
export type ParsedAppriseConfig =
| { ok: true; mode: 'keyed'; tags?: string }
| { ok: true; mode: 'stateless'; urls: string[]; urlsJoined: string }
| { ok: false; reason: string };
const APPRISE_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
const KEYED_KEYS = new Set(['tags']);
const STATELESS_KEYS = new Set(['urls']);
const INVALID_STORED = 'Apprise configuration is missing or invalid';
/** Path segment after `/notify/` when the path is `/notify/{key}` (key not yet validated). */
function notifyKeyFromPath(path: string): string | null {
const match = path.match(/^\/notify\/([^/]+)$/);
return match ? match[1] : null;
}
export const cleanStackPatterns = (patterns: string[]): string[] =>
[...new Set(patterns.map(p => p.trim()).filter(Boolean))];
@@ -10,6 +58,260 @@ export function validateHttpsUrl(value: unknown): string | null {
return null;
}
export function classifyAppriseEndpoint(endpoint: string): 'keyed' | 'stateless' | null {
try {
const path = new URL(endpoint).pathname.replace(/\/$/, '');
const key = notifyKeyFromPath(path);
if (key !== null) return APPRISE_NOTIFY_KEY.test(key) ? 'keyed' : null;
if (path === '/notify') return 'stateless';
return null;
} catch {
return null;
}
}
/** Thin classifier for tests; write validation uses validateNotificationChannel. */
export function classifyAppriseConfig(endpoint: string, config: unknown): { mode: 'keyed' | 'stateless'; urls?: string[] } | null {
const mode = classifyAppriseEndpoint(endpoint);
if (!mode) return null;
if (mode === 'keyed') {
if (config !== undefined && config !== null && (typeof config !== 'object' || Array.isArray(config))) return null;
return { mode: 'keyed' };
}
if (!config || typeof config !== 'object' || Array.isArray(config)) return null;
const urls = typeof (config as { urls?: unknown }).urls === 'string'
? splitServiceUrls((config as { urls: string }).urls)
: [];
return urls.length > 0 ? { mode: 'stateless', urls } : null;
}
export function isPublicAppriseConfigShape(config: unknown): boolean {
if (!config || typeof config !== 'object' || Array.isArray(config)) return false;
const record = config as Record<string, unknown>;
return 'has_urls' in record || 'providers' in record || 'url_count' in record || 'mode' in record || 'secrets_redacted' in record;
}
function unknownKeyError(record: Record<string, unknown>, allowed: Set<string>): string | null {
for (const key of Object.keys(record)) {
if (!allowed.has(key)) return `unknown Apprise config field: ${key}`;
}
return null;
}
function splitServiceUrls(urls: string): string[] {
return urls.split(/[\s,]+/).map(url => url.trim()).filter(Boolean);
}
function validateServiceUrlToken(token: string): string | null {
if (token.length > 2000) return 'Apprise service URLs must be 2000 characters or fewer';
if (!APPRISE_SCHEME.test(token)) return 'each Apprise service URL must include a URI scheme';
return null;
}
function asRecord(config: unknown): Record<string, unknown> | null {
if (config === undefined || config === null) return {};
if (typeof config !== 'object' || Array.isArray(config)) return null;
return config as Record<string, unknown>;
}
export function validateNotificationChannel(type: unknown, url: unknown, config?: unknown): string | null {
if (typeof type !== 'string' || !(NOTIFICATION_CHANNEL_TYPES as readonly string[]).includes(type)) {
return `type must be ${NOTIFICATION_CHANNEL_TYPES.join(', ')}`;
}
if (type !== 'apprise') return validateHttpsUrl(url);
if (typeof url !== 'string') return 'must be a valid Apprise URL';
let parsed: URL;
try { parsed = new URL(url); } catch { return 'is not a valid URL'; }
if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.host || parsed.username || parsed.password || parsed.hash) {
return 'must be an HTTP or HTTPS Apprise URL without credentials or a fragment';
}
if (isPublicAppriseConfigShape(config)) {
return 'must use raw Apprise config ({ urls } or { tags }), not a public summary';
}
const path = parsed.pathname.replace(/\/$/, '');
const notifyKey = notifyKeyFromPath(path);
if (notifyKey !== null && !APPRISE_NOTIFY_KEY.test(notifyKey)) {
return 'Apprise notify key must be 1128 characters of letters, digits, underscore, or dash';
}
const mode = classifyAppriseEndpoint(url);
if (!mode) return 'must use /notify or /notify/{key} with valid configuration';
const record = asRecord(config);
if (!record) return 'must use /notify or /notify/{key} with valid configuration';
if (mode === 'keyed') {
const keyErr = unknownKeyError(record, KEYED_KEYS);
if (keyErr) return keyErr;
if (record.urls !== undefined) return 'keyed Apprise endpoints cannot include urls';
if (record.tags !== undefined && typeof record.tags !== 'string') return 'Apprise tags must be a string';
return null;
}
const keyErr = unknownKeyError(record, STATELESS_KEYS);
if (keyErr) return keyErr;
if (record.tags !== undefined) return 'stateless Apprise endpoints cannot include tags';
if (typeof record.urls !== 'string' || !record.urls.trim()) {
return 'stateless Apprise endpoints require a nonempty urls string';
}
const urls = splitServiceUrls(record.urls);
if (urls.length === 0) return 'stateless Apprise endpoints require a nonempty urls string';
for (const token of urls) {
const tokenErr = validateServiceUrlToken(token);
if (tokenErr) return tokenErr;
}
return null;
}
/** Persist shape: keyed empty → `{}`; omit empty tags; stateless → `{ urls }`. */
export function normalizeAppriseStoredJson(endpoint: string, config: unknown): string {
const mode = classifyAppriseEndpoint(endpoint);
if (mode === 'keyed') {
const record = asRecord(config) ?? {};
const tags = typeof record.tags === 'string' ? record.tags.trim() : '';
return tags ? JSON.stringify({ tags }) : '{}';
}
const record = asRecord(config) ?? {};
const urls = typeof record.urls === 'string' ? record.urls.trim() : '';
return JSON.stringify({ urls });
}
export function parseStoredAppriseConfig(endpoint: string, configJson: string | null | undefined): ParsedAppriseConfig {
const mode = classifyAppriseEndpoint(endpoint);
if (!mode) return { ok: false, reason: INVALID_STORED };
if (configJson == null || configJson === '') {
if (mode === 'keyed') return { ok: true, mode: 'keyed' };
return { ok: false, reason: INVALID_STORED };
}
let parsed: unknown;
try {
parsed = JSON.parse(configJson);
} catch {
return { ok: false, reason: INVALID_STORED };
}
const err = validateNotificationChannel('apprise', endpoint, parsed);
if (err) return { ok: false, reason: INVALID_STORED };
// validateNotificationChannel already confirmed parsed is a plain object
// whose only fields match the mode (tags for keyed, urls for stateless),
// so no further shape narrowing is needed here.
if (mode === 'keyed') {
const tags = typeof (parsed as { tags?: string }).tags === 'string' ? (parsed as { tags: string }).tags.trim() : '';
return tags ? { ok: true, mode: 'keyed', tags } : { ok: true, mode: 'keyed' };
}
const urlsJoined = (parsed as { urls: string }).urls.trim();
const urls = splitServiceUrls(urlsJoined);
return { ok: true, mode: 'stateless', urls, urlsJoined };
}
export function storedAppriseToWriteConfig(parsed: Extract<ParsedAppriseConfig, { ok: true }>): AppriseWriteConfig {
if (parsed.mode === 'keyed') {
return parsed.tags ? { tags: parsed.tags } : {};
}
return { urls: parsed.urlsJoined };
}
/** Reconstruct the write config to preserve on an agent/route write that omits `config`. */
export function resolvePreservedAppriseConfig(
endpoint: string,
existingConfigJson: string | null | undefined,
): { ok: true; config: AppriseWriteConfig } | { ok: false; error: string } {
const stored = parseStoredAppriseConfig(endpoint, existingConfigJson);
if (!stored.ok) {
return {
ok: false,
error: 'Stored Apprise configuration is invalid for this endpoint; provide a complete replacement',
};
}
return { ok: true, config: storedAppriseToWriteConfig(stored) };
}
/**
* Shared preserve-on-write guard for agent/route Apprise writes: blocks a
* write that echoes a redacted public URL, config, or DTO shape back as raw
* input. Identical across `agents.ts` POST and `notification-routes` PUT.
*/
export function redactedChannelWriteError(
type: string,
effectiveUrl: unknown,
effectiveConfig: unknown,
rawConfig: unknown,
): string | null {
const configText = effectiveConfig === undefined ? '' : JSON.stringify(effectiveConfig) ?? '';
if (
(typeof effectiveUrl === 'string' && effectiveUrl.includes('<redacted>'))
|| configText.includes('<redacted>')
|| (type === 'apprise' && rawConfig !== undefined && isPublicAppriseConfigShape(rawConfig))
) {
return 'Provide raw channel credentials to replace redacted values';
}
return null;
}
function providerSchemes(urls: string[]): string[] {
const schemes: string[] = [];
for (const value of urls) {
if (!APPRISE_SCHEME.test(value)) continue;
const scheme = value.split(':', 1)[0].toLowerCase();
if (scheme && !schemes.includes(scheme)) schemes.push(scheme);
}
return schemes;
}
function publicAppriseConfig(url: string, config: string | null): PublicAppriseConfig | null {
const parsed = parseStoredAppriseConfig(url, config);
if (!parsed.ok) {
console.warn(`[apprise] stored config unreadable for public DTO (${parsed.reason})`);
return null;
}
if (parsed.mode === 'keyed') {
return { mode: 'keyed', tags: parsed.tags, has_urls: false };
}
const providers = providerSchemes(parsed.urls);
return {
mode: 'stateless',
has_urls: parsed.urls.length > 0,
...(providers.length > 0 ? { providers } : {}),
url_count: parsed.urls.length,
};
}
export function maskAppriseEndpoint(value: string): string {
try {
const parsed = new URL(value);
const path = parsed.pathname.replace(/\/$/, '');
const key = notifyKeyFromPath(path);
if (key !== null && APPRISE_NOTIFY_KEY.test(key)) return `${parsed.origin}/notify/<redacted>`;
return `${parsed.origin}/notify`;
} catch {
return '<invalid url>';
}
}
export function serializePublicAgent(agent: Agent): PublicAgent {
const isApprise = agent.type === 'apprise';
return {
...agent,
type: agent.type,
url: isApprise ? maskAppriseEndpoint(agent.url) : agent.url,
config: isApprise ? publicAppriseConfig(agent.url, agent.config ?? null) : null,
secrets_redacted: isApprise,
};
}
export function serializePublicNotificationRoute(route: NotificationRoute): PublicNotificationRoute {
const isApprise = route.channel_type === 'apprise';
return {
...route,
channel_type: route.channel_type,
channel_url: isApprise ? maskAppriseEndpoint(route.channel_url) : route.channel_url,
config: isApprise ? publicAppriseConfig(route.channel_url, route.config ?? null) : null,
secrets_redacted: isApprise,
};
}
/**
* Mask a channel webhook URL for logging. Discord/Slack/custom webhook URLs
* embed their auth token in the path (and sometimes the query), so only the
@@ -20,8 +322,6 @@ export function maskWebhookUrl(value: unknown): string {
if (typeof value !== 'string' || value.length === 0) return '<no url>';
try {
const { origin, pathname, search } = new URL(value);
// pathname is normalized to '/' for an origin-only URL, so anything else
// (or any query string) is a token-bearing segment we must not log.
const hasSecret = pathname !== '/' || search !== '';
return hasSecret ? `${origin}/<redacted>` : origin;
} catch {
+30 -6
View File
@@ -4,7 +4,14 @@ import { authMiddleware } from '../middleware/auth';
import { requireAdmin } from '../middleware/tierGates';
import { isDebugEnabled } from '../utils/debug';
import { sanitizeForLog } from '../utils/safeLog';
import { NOTIFICATION_CHANNEL_TYPES, validateHttpsUrl } from '../helpers/notificationChannels';
import {
NOTIFICATION_CHANNEL_TYPES,
normalizeAppriseStoredJson,
redactedChannelWriteError,
resolvePreservedAppriseConfig,
serializePublicAgent,
validateNotificationChannel,
} from '../helpers/notificationChannels';
export const agentsRouter = Router();
@@ -12,7 +19,7 @@ agentsRouter.get('/', authMiddleware, async (req: Request, res: Response): Promi
try {
const nodeId = req.nodeId ?? 0;
const agents = DatabaseService.getInstance().getAgents(nodeId);
res.json(agents);
res.json(agents.map(serializePublicAgent));
} catch (error) {
console.error('Failed to fetch agents:', error);
res.status(500).json({ error: 'Failed to fetch agents' });
@@ -22,19 +29,36 @@ agentsRouter.get('/', authMiddleware, async (req: Request, res: Response): Promi
agentsRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
try {
const { type, url, enabled } = req.body;
const { type, url, enabled, config } = 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 urlErr = validateHttpsUrl(url);
if (urlErr) { res.status(400).json({ error: `url ${urlErr}` }); return; }
if (typeof enabled !== 'boolean') {
res.status(400).json({ error: 'enabled must be a boolean' });
return;
}
const nodeId = req.nodeId ?? 0;
DatabaseService.getInstance().upsertAgent(nodeId, { type, url, enabled });
const existing = DatabaseService.getInstance().getAgents(nodeId).find(agent => agent.type === type);
const effectiveUrl = url === undefined ? existing?.url : url;
let effectiveConfig: unknown = config ?? null;
if (type === 'apprise' && config === undefined && existing) {
const resolved = resolvePreservedAppriseConfig(typeof effectiveUrl === 'string' ? effectiveUrl : existing.url, existing.config);
if (!resolved.ok) { res.status(400).json({ error: resolved.error }); return; }
effectiveConfig = resolved.config;
}
const redactedErr = redactedChannelWriteError(type, effectiveUrl, effectiveConfig, config);
if (redactedErr) { res.status(400).json({ error: redactedErr }); return; }
const channelErr = validateNotificationChannel(type, effectiveUrl, effectiveConfig);
if (channelErr) { res.status(400).json({ error: `url ${channelErr}` }); return; }
DatabaseService.getInstance().upsertAgent(nodeId, {
type,
url: effectiveUrl.trim(),
enabled,
config: type === 'apprise' ? normalizeAppriseStoredJson(effectiveUrl.trim(), effectiveConfig) : null,
});
console.log('[Agents] Agent %s updated', sanitizeForLog(type));
if (isDebugEnabled()) console.log('[Agents:diag] Agent %s upsert: enabled=%s', sanitizeForLog(type), sanitizeForLog(enabled));
res.json({ success: true });
+3 -2
View File
@@ -16,7 +16,7 @@ interface AgentStatus {
export interface ConfigurationStatus {
tier: LicenseTier;
notifications: {
agents: { discord: AgentStatus; slack: AgentStatus; webhook: AgentStatus };
agents: { discord: AgentStatus; slack: AgentStatus; webhook: AgentStatus; apprise: AgentStatus };
alertRules: number;
routingRules: { count: number; enabledCount: number; locked: boolean };
suppressionRules: { total: number; enabledCount: number };
@@ -57,7 +57,7 @@ export function buildLocalConfigurationStatus(
const db = DatabaseService.getInstance();
const agents = db.getAgents(nodeId);
const agentByType = (type: 'discord' | 'slack' | 'webhook'): AgentStatus => {
const agentByType = (type: 'discord' | 'slack' | 'webhook' | 'apprise'): AgentStatus => {
const a = agents.find(ag => ag.type === type);
return { configured: !!a?.url, enabled: a?.enabled ?? false };
};
@@ -96,6 +96,7 @@ export function buildLocalConfigurationStatus(
discord: agentByType('discord'),
slack: agentByType('slack'),
webhook: agentByType('webhook'),
apprise: agentByType('apprise'),
},
alertRules,
// Notification routing is available on every tier.
+3 -1
View File
@@ -50,6 +50,7 @@ import { collectFleetLabelSummaries } from '../helpers/fleetLabelSummary';
import { runLocalLabelAssign, validateLabelTemplate, validateRemoteAssignResults, failAllAssign, type AssignNodeResult } from '../helpers/fleetLabelAssign';
import { MAX_ASSIGNMENTS } from '../helpers/constants';
import { buildLocalConfigurationStatus, type ConfigurationStatus } from './dashboard';
import { normalizeRemoteConfigurationStatus } from '../helpers/configurationStatus';
import { buildLocalGraph, mergeFleetGraph, isLocalDependencyGraph, type FleetNodeGraphResult } from '../services/DependencyGraphService';
import { buildNodeLabelInventory, VALID_LABEL_SOURCES, type NodeLabelInventory } from '../services/LabelInventoryService';
import { labelInventoryOptionsFromRequest, requireRevealAdmin } from '../helpers/labelInventoryRequest';
@@ -687,7 +688,8 @@ fleetRouter.get('/configuration', authMiddleware, async (req: Request, res: Resp
signal: AbortSignal.timeout(10000),
},
);
const configuration = resp.ok ? (await resp.json() as ConfigurationStatus) : null;
const raw = resp.ok ? (await resp.json() as ConfigurationStatus) : null;
const configuration = raw ? normalizeRemoteConfigurationStatus(raw) : null;
return {
id: node.id,
name: node.name,
+55 -16
View File
@@ -7,9 +7,15 @@ import { authMiddleware } from '../middleware/auth';
import { requireAdmin, requireNodeProxy } from '../middleware/tierGates';
import {
NOTIFICATION_CHANNEL_TYPES,
validateHttpsUrl,
serializePublicNotificationRoute,
validateNotificationChannel,
cleanStackPatterns,
maskWebhookUrl,
normalizeAppriseStoredJson,
parseStoredAppriseConfig,
redactedChannelWriteError,
resolvePreservedAppriseConfig,
storedAppriseToWriteConfig,
} from '../helpers/notificationChannels';
import {
deleteSuppressionRuleFromFleet,
@@ -246,14 +252,14 @@ 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 } = req.body;
const { type, url, config } = 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 urlErr = validateHttpsUrl(url);
if (urlErr) { res.status(400).json({ error: `url ${urlErr}` }); return; }
await NotificationService.getInstance().testDispatch(type, url);
const channelErr = validateNotificationChannel(type, url, config);
if (channelErr) { res.status(400).json({ error: `url ${channelErr}` }); return; }
await NotificationService.getInstance().testDispatch(type, url, config);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: 'Test failed', details: getErrorMessage(error, String(error)) });
@@ -266,7 +272,7 @@ notificationRoutesRouter.get('/', authMiddleware, (req: Request, res: Response):
if (!requireAdmin(req, res)) return;
try {
const routes = DatabaseService.getInstance().getNotificationRoutes();
res.json(routes);
res.json(routes.map(serializePublicNotificationRoute));
} catch (error) {
console.error('Failed to fetch notification routes:', error);
res.status(500).json({ error: 'Failed to fetch notification routes' });
@@ -276,7 +282,7 @@ notificationRoutesRouter.get('/', authMiddleware, (req: Request, res: Response):
notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
try {
const { name, node_id: rawNodeId, stack_patterns, label_ids, categories, channel_type, channel_url, priority, enabled } = req.body;
const { name, node_id: rawNodeId, stack_patterns, label_ids, categories, channel_type, channel_url, config, priority, enabled } = req.body;
if (!name || typeof name !== 'string' || !name.trim()) {
res.status(400).json({ error: 'Name is required' });
@@ -299,7 +305,7 @@ notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Res
res.status(400).json({ error: `channel_type must be ${NOTIFICATION_CHANNEL_TYPES.join(', ')}` });
return;
}
const channelUrlErr = validateHttpsUrl(channel_url);
const channelUrlErr = validateNotificationChannel(channel_type, channel_url, config);
if (channelUrlErr) { res.status(400).json({ error: `channel_url ${channelUrlErr}` }); return; }
if (priority !== undefined && (typeof priority !== 'number' || !Number.isFinite(priority))) {
res.status(400).json({ error: 'priority must be a finite number' });
@@ -315,6 +321,7 @@ notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Res
categories: Array.isArray(categories) && categories.length > 0 ? (categories as NotificationCategory[]) : null,
channel_type,
channel_url: channel_url.trim(),
config: channel_type === 'apprise' ? normalizeAppriseStoredJson(channel_url.trim(), config) : null,
priority: typeof priority === 'number' ? priority : 0,
enabled: enabled !== false,
created_at: now,
@@ -322,7 +329,7 @@ notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Res
});
console.log(`[Routes] Route "${sanitizeForLog(route.name)}" created (id=${route.id})`);
if (isDebugEnabled()) console.log(`[Routes:diag] Route "${sanitizeForLog(route.name)}" created with patterns=[${sanitizeForLog(cleanedPatterns.join(', '))}], channel=${channel_type}`);
res.status(201).json(route);
res.status(201).json(serializePublicNotificationRoute(route));
} catch (error) {
console.error('Failed to create notification route:', error);
res.status(500).json({ error: 'Failed to create notification route' });
@@ -338,7 +345,7 @@ notificationRoutesRouter.put('/:id', authMiddleware, async (req: Request, res: R
const existing = DatabaseService.getInstance().getNotificationRoute(id);
if (!existing) { res.status(404).json({ error: 'Route not found' }); return; }
const { name, node_id: rawNodeId, stack_patterns, label_ids, categories, channel_type, channel_url, priority, enabled } = req.body;
const { name, node_id: rawNodeId, stack_patterns, label_ids, categories, channel_type, channel_url, config, priority, enabled } = req.body;
if (name !== undefined && (typeof name !== 'string' || !name.trim())) {
res.status(400).json({ error: 'Name must be a non-empty string' });
@@ -368,10 +375,29 @@ notificationRoutesRouter.put('/:id', authMiddleware, async (req: Request, res: R
res.status(400).json({ error: `channel_type must be ${NOTIFICATION_CHANNEL_TYPES.join(', ')}` });
return;
}
if (channel_url !== undefined) {
const urlErr = validateHttpsUrl(channel_url);
if (urlErr) { res.status(400).json({ error: `channel_url ${urlErr}` }); return; }
const typeChanged = channel_type !== undefined && channel_type !== existing.channel_type;
// Type changes replace credentials; never reuse a prior channel's URL/config (ciphertext or plaintext).
if (typeChanged && (typeof channel_url !== 'string' || !channel_url.trim())) {
res.status(400).json({ error: 'channel_url is required when changing channel_type' });
return;
}
const effectiveType = channel_type ?? existing.channel_type;
const effectiveUrl = channel_url !== undefined ? String(channel_url).trim() : existing.channel_url;
let effectiveConfig: unknown = config ?? null;
if (effectiveType === 'apprise' && config === undefined) {
if (typeChanged) {
// Fresh Apprise credentials: empty keyed (or stateless urls required via validate).
effectiveConfig = null;
} else {
const resolved = resolvePreservedAppriseConfig(effectiveUrl, existing.config);
if (!resolved.ok) { res.status(400).json({ error: resolved.error }); return; }
effectiveConfig = resolved.config;
}
}
const redactedErr = redactedChannelWriteError(effectiveType, effectiveUrl, effectiveConfig, config);
if (redactedErr) { res.status(400).json({ error: redactedErr }); return; }
const urlErr = validateNotificationChannel(effectiveType, effectiveUrl, effectiveConfig);
if (urlErr) { res.status(400).json({ error: `channel_url ${urlErr}` }); return; }
if (priority !== undefined && (typeof priority !== 'number' || !Number.isFinite(priority))) {
res.status(400).json({ error: 'priority must be a finite number' });
return;
@@ -388,7 +414,9 @@ notificationRoutesRouter.put('/:id', authMiddleware, async (req: Request, res: R
if ('label_ids' in req.body) updates.label_ids = Array.isArray(label_ids) && label_ids.length > 0 ? label_ids : null;
if ('categories' in req.body) updates.categories = Array.isArray(categories) && categories.length > 0 ? categories : null;
if (channel_type !== undefined) updates.channel_type = channel_type;
if (channel_url !== undefined) updates.channel_url = channel_url.trim();
if (channel_url !== undefined || typeChanged) updates.channel_url = effectiveUrl;
if (effectiveType === 'apprise') updates.config = normalizeAppriseStoredJson(effectiveUrl, effectiveConfig);
else if (typeChanged || channel_type !== undefined) updates.config = null;
if (priority !== undefined) updates.priority = priority;
if (enabled !== undefined) updates.enabled = enabled;
@@ -397,7 +425,7 @@ notificationRoutesRouter.put('/:id', authMiddleware, async (req: Request, res: R
const updated = db.getNotificationRoute(id);
console.log(`[Routes] Route ${id} updated`);
if (isDebugEnabled()) console.log(`[Routes:diag] Route ${id} update fields: ${Object.keys(updates).filter(k => k !== 'updated_at')}`);
res.json(updated);
res.json(serializePublicNotificationRoute(updated!));
} catch (error) {
console.error('Failed to update notification route:', error);
res.status(500).json({ error: 'Failed to update notification route' });
@@ -430,7 +458,18 @@ notificationRoutesRouter.post('/:id/test', authMiddleware, async (req: Request,
if (!route) { res.status(404).json({ error: 'Route not found' }); return; }
if (isDebugEnabled()) console.log(`[Routes:diag] Test dispatch for route ${id} (${route.channel_type} -> ${maskWebhookUrl(route.channel_url)})`);
await NotificationService.getInstance().testDispatch(route.channel_type, route.channel_url);
let testConfig: unknown;
if (route.channel_type === 'apprise') {
const stored = parseStoredAppriseConfig(route.channel_url, route.config);
if (!stored.ok) {
res.status(400).json({ error: stored.reason });
return;
}
testConfig = storedAppriseToWriteConfig(stored);
} else {
testConfig = route.config ? JSON.parse(route.config) : undefined;
}
await NotificationService.getInstance().testDispatch(route.channel_type, route.channel_url, testConfig);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: 'Test failed', details: getErrorMessage(error, String(error)) });
+105 -19
View File
@@ -16,9 +16,10 @@ function isPilotMode(): boolean {
export interface Agent {
id?: number;
type: 'discord' | 'slack' | 'webhook';
type: 'discord' | 'slack' | 'webhook' | 'apprise';
url: string;
enabled: boolean;
config?: string | null;
}
export interface GlobalSetting {
@@ -613,8 +614,9 @@ export interface NotificationRoute {
stack_patterns: string[];
label_ids: number[] | null;
categories: string[] | null;
channel_type: 'discord' | 'slack' | 'webhook';
channel_type: 'discord' | 'slack' | 'webhook' | 'apprise';
channel_url: string;
config?: string | null;
priority: number;
enabled: boolean;
created_at: number;
@@ -909,6 +911,7 @@ export class DatabaseService {
this.migrateNotificationRoutes();
this.migrateNotificationRoutesNodeId();
this.migrateNotificationRoutesMatchers();
this.migrateNotificationChannelConfig();
this.migrateNotificationSuppressionRules();
this.migrateNotificationHistoryContext();
this.migrateScanPolicyFleetColumns();
@@ -1907,6 +1910,11 @@ export class DatabaseService {
this.tryAddColumn('notification_routes', 'categories', 'TEXT NULL');
}
private migrateNotificationChannelConfig(): void {
this.tryAddColumn('agents', 'config', 'TEXT NULL');
this.tryAddColumn('notification_routes', 'config', 'TEXT NULL');
}
private migrateNotificationSuppressionRules(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS notification_suppression_rules (
@@ -2295,36 +2303,101 @@ export class DatabaseService {
// --- Agents ---
/** Encrypt Apprise secrets at rest so a downgraded binary's SELECT * cannot return raw keys/URLs. */
private sealAppriseSecret(value: string | null | undefined): string | null {
if (value == null) return null;
if (value === '') return value;
const crypto = CryptoService.getInstance();
return crypto.isEncrypted(value) ? value : crypto.encrypt(value);
}
private openAppriseSecret(value: string | null | undefined): string | null {
if (value == null) return null;
return CryptoService.getInstance().decrypt(value);
}
/** Seal or pass through url/config depending on whether the channel is Apprise. */
private storeAppriseFields(
isApprise: boolean,
url: string,
config: string | null | undefined,
): { url: string; config: string | null } {
if (!isApprise) return { url, config: config ?? null };
return {
url: this.sealAppriseSecret(url) ?? '',
config: this.sealAppriseSecret(config ?? null),
};
}
/**
* Decrypt Apprise fields for one row. Corrupt ciphertext or a key mismatch
* must not throw out of list/dispatch paths: one bad Apprise row would
* otherwise 500 GET /agents and silently drop every notification channel.
* Empty url/config forces the operator to re-enter credentials to repair.
*/
private loadAppriseFields(
isApprise: boolean,
url: string,
config: string | null | undefined,
): { url: string; config: string | null } {
if (!isApprise) return { url, config: config ?? null };
try {
return {
url: this.openAppriseSecret(url) ?? '',
config: this.openAppriseSecret(config ?? null),
};
} catch (e) {
console.error(
'[DatabaseService] Failed to decrypt Apprise credentials; isolating row:',
(e as Error).message,
);
return { url: '', config: null };
}
}
private mapAgentRow(row: any): Agent {
const type = row.type as Agent['type'];
const fields = this.loadAppriseFields(type === 'apprise', row.url as string, row.config as string | null);
return {
...row,
type,
enabled: row.enabled === 1,
url: fields.url,
config: fields.config,
};
}
public getAgents(nodeId: number): Agent[] {
const stmt = this.db.prepare('SELECT * FROM agents WHERE node_id = ?');
return stmt.all(nodeId).map((row: any) => ({
...row,
enabled: row.enabled === 1
}));
return stmt.all(nodeId).map((row: any) => this.mapAgentRow(row));
}
public getEnabledAgents(nodeId: number): Agent[] {
const stmt = this.db.prepare('SELECT * FROM agents WHERE node_id = ? AND enabled = 1');
return stmt.all(nodeId).map((row: any) => ({
...row,
enabled: row.enabled === 1
}));
return stmt.all(nodeId).map((row: any) => this.mapAgentRow(row));
}
public upsertAgent(nodeId: number, agent: Agent): void {
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 = ? WHERE node_id = ? AND type = ?');
stmt.run(agent.url, agent.enabled ? 1 : 0, nodeId, agent.type);
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);
} else {
const stmt = this.db.prepare('INSERT INTO agents (node_id, type, url, enabled) VALUES (?, ?, ?, ?)');
stmt.run(nodeId, agent.type, agent.url, agent.enabled ? 1 : 0);
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);
}
}
// --- Notification Routes ---
private parseNotificationRoute(row: Record<string, unknown>): NotificationRoute {
const channel_type = row.channel_type as 'discord' | 'slack' | 'webhook' | 'apprise';
const fields = this.loadAppriseFields(
channel_type === 'apprise',
row.channel_url as string,
row.config as string | null,
);
return {
id: row.id as number,
name: row.name as string,
@@ -2332,8 +2405,9 @@ export class DatabaseService {
stack_patterns: JSON.parse(row.stack_patterns as string) as string[],
label_ids: row.label_ids ? JSON.parse(row.label_ids as string) as number[] : null,
categories: row.categories ? JSON.parse(row.categories as string) as string[] : null,
channel_type: row.channel_type as 'discord' | 'slack' | 'webhook',
channel_url: row.channel_url as string,
channel_type,
channel_url: fields.url,
config: fields.config,
priority: row.priority as number,
enabled: row.enabled === 1,
created_at: row.created_at as number,
@@ -2366,8 +2440,9 @@ export class DatabaseService {
}
public createNotificationRoute(route: Omit<NotificationRoute, 'id'>): NotificationRoute {
const stored = this.storeAppriseFields(route.channel_type === 'apprise', route.channel_url, route.config);
const result = this.db.prepare(
'INSERT INTO notification_routes (name, node_id, stack_patterns, label_ids, categories, channel_type, channel_url, priority, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
'INSERT INTO notification_routes (name, node_id, stack_patterns, label_ids, categories, channel_type, channel_url, config, priority, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(
route.name,
route.node_id ?? null,
@@ -2375,7 +2450,8 @@ export class DatabaseService {
route.label_ids ? JSON.stringify(route.label_ids) : null,
route.categories ? JSON.stringify(route.categories) : null,
route.channel_type,
route.channel_url,
stored.url,
stored.config,
route.priority,
route.enabled ? 1 : 0,
route.created_at,
@@ -2387,6 +2463,9 @@ export class DatabaseService {
public updateNotificationRoute(id: number, updates: Partial<Omit<NotificationRoute, 'id' | 'created_at'>>): void {
const fields: string[] = [];
const values: unknown[] = [];
const existing = this.getNotificationRoute(id);
const effectiveType = updates.channel_type ?? existing?.channel_type;
const sealApprise = effectiveType === 'apprise';
if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); }
if ('node_id' in updates) { fields.push('node_id = ?'); values.push(updates.node_id ?? null); }
@@ -2394,7 +2473,14 @@ export class DatabaseService {
if ('label_ids' in updates) { fields.push('label_ids = ?'); values.push(updates.label_ids ? JSON.stringify(updates.label_ids) : null); }
if ('categories' in updates) { fields.push('categories = ?'); values.push(updates.categories ? JSON.stringify(updates.categories) : null); }
if (updates.channel_type !== undefined) { fields.push('channel_type = ?'); values.push(updates.channel_type); }
if (updates.channel_url !== undefined) { fields.push('channel_url = ?'); values.push(updates.channel_url); }
if (updates.channel_url !== undefined) {
fields.push('channel_url = ?');
values.push(sealApprise ? (this.sealAppriseSecret(updates.channel_url) ?? '') : updates.channel_url);
}
if ('config' in updates) {
fields.push('config = ?');
values.push(sealApprise ? this.sealAppriseSecret(updates.config ?? null) : (updates.config ?? null));
}
if (updates.priority !== undefined) { fields.push('priority = ?'); values.push(updates.priority); }
if (updates.enabled !== undefined) { fields.push('enabled = ?'); values.push(updates.enabled ? 1 : 0); }
if (updates.updated_at !== undefined) { fields.push('updated_at = ?'); values.push(updates.updated_at); }
+64 -7
View File
@@ -12,6 +12,13 @@ import {
matchesNotificationFilters,
ruleNeedsStackLabels,
} from '../helpers/notificationMatchers';
import {
type NotificationChannelType,
type ParsedAppriseConfig,
normalizeAppriseStoredJson,
parseStoredAppriseConfig,
validateNotificationChannel,
} from '../helpers/notificationChannels';
export type NotificationCategory =
| 'deploy_success'
@@ -65,7 +72,13 @@ export const ALL_SUPPRESSIBLE_CATEGORIES: readonly NotificationCategory[] = [
const WEBHOOK_TIMEOUT_MS = 10_000;
/** Valid notification channel types for defense-in-depth validation. */
const ALLOWED_CHANNEL_TYPES = new Set(['discord', 'slack', 'webhook']);
const ALLOWED_CHANNEL_TYPES = new Set<NotificationChannelType>(['discord', 'slack', 'webhook', 'apprise']);
export class NotificationDeliveryError extends Error {
public constructor(message: string, public readonly status: number | null, public readonly retryable: boolean) {
super(message);
}
}
export class NotificationService {
private static instance: NotificationService;
@@ -251,7 +264,7 @@ export class NotificationService {
if (isDebugEnabled()) console.log(`[Notify:diag] Matched ${matched.length} route(s) for stack "${sanitizeForLog(stackName ?? '(none)')}", category="${sanitizeForLog(category)}"`);
await Promise.allSettled(
matched.map(route =>
this.sendToChannel(route.channel_type, route.channel_url, level, sanitized)
this.sendToChannel(route.channel_type, route.channel_url, level, sanitized, route.config)
.then(() => {
if (isDebugEnabled()) console.log(`[Notify:diag] Dispatched ${level} via route "${sanitizeForLog(route.name)}" (${route.channel_type})`);
})
@@ -275,7 +288,7 @@ export class NotificationService {
if (isDebugEnabled()) console.log(`[Notify:diag] Falling back to ${agents.length} global agent(s)`);
await Promise.allSettled(
agents.map(agent =>
this.sendToChannel(agent.type, agent.url, level, sanitized)
this.sendToChannel(agent.type, agent.url, level, sanitized, agent.config)
.then(() => {
if (isDebugEnabled()) console.log(`[Notify:diag] Dispatched ${level} via global agent (${agent.type})`);
})
@@ -302,22 +315,66 @@ export class NotificationService {
}
}
private async sendToChannel(type: string, url: string, level: 'info' | 'warning' | 'error', message: string): Promise<void> {
private async sendToChannel(type: string, url: string, level: 'info' | 'warning' | 'error', message: string, config?: string | null): Promise<void> {
if (type === 'discord') {
await this.sendDiscordWebhook(url, level, message);
} else if (type === 'slack') {
await this.sendSlackWebhook(url, level, message);
} else if (type === 'webhook') {
await this.sendCustomWebhook(url, level, message);
} else if (type === 'apprise') {
const parsed = parseStoredAppriseConfig(url, config);
if (!parsed.ok) {
throw new NotificationDeliveryError(parsed.reason, null, false);
}
await this.sendAppriseNotify(url, level, message, parsed);
} else {
throw new Error(`Unsupported channel type: ${type}`);
}
}
public async testDispatch(type: 'discord' | 'slack' | 'webhook', url: string) {
public async testDispatch(type: NotificationChannelType, url: string, config?: unknown) {
if (!ALLOWED_CHANNEL_TYPES.has(type)) throw new Error(`Invalid notification type: ${type}`);
if (!url || !url.startsWith('https://')) throw new Error('URL must use HTTPS');
await this.sendToChannel(type, url, 'info', '🔌 Test Notification from Sencho!');
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));
await this.sendToChannel(type, url, 'info', '🔌 Test Notification from Sencho!', stored);
}
private async sendAppriseNotify(
url: string,
level: 'info' | 'warning' | 'error',
message: string,
config: Extract<ParsedAppriseConfig, { ok: true }>,
): Promise<void> {
const payload: Record<string, string> = {
title: `Sencho Alert [${level.toUpperCase()}]`,
body: message,
type: level === 'error' ? 'failure' : level,
};
if (config.mode === 'stateless') payload.urls = config.urlsJoined;
else if (config.tags) payload.tag = config.tags;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS),
});
if (response.status === 204) throw new NotificationDeliveryError('Apprise returned no delivery (HTTP 204)', 204, false);
if (response.status >= 400 && response.status < 500) {
throw new NotificationDeliveryError(`Apprise responded with HTTP ${response.status}`, response.status, false);
}
if (!response.ok) throw new NotificationDeliveryError(`Apprise 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 ? 'Apprise request timed out' : 'Apprise request failed',
null,
true,
);
}
}
private async sendDiscordWebhook(url: string, level: 'info' | 'warning' | 'error', message: string) {