Files
dashcaddy/dashcaddy-api/routes/notifications.js
T
Hermes 7e4ee60dcf
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
[glm-grade=B] fix(notifications): repair UI/API contract drift — SMTP auth, event gate, test button (DC-092)
Three user-facing notification features were silently dead from contract
drift between the settings UI and the backend:

1. SMTP auth never applied: the UI sent email.user/email.pass while the
   manager read username/password. Route now normalizes aliases; legacy
   config files canonicalize at load.
2. Event toggles were cosmetic: UI sent camelCase keys (containerDown),
   the send() gate read kebab-case ('container-down'). EVENT_ALIASES now
   folds every known spelling (manager gate, route store, legacy files);
   UI sends and reads canonical keys.
3. Deploy/auto-restart notifications always dropped: deploy-success/
   deploy-failed/auto-restart were missing from DEFAULT events, and
   send('test') was itself gated -> the Test button was a no-op. Defaults
   added; 'test' bypasses the gate.

Also: strict boolean contract (string 'false' for secure/enabled rejected
— previously coerced truthy, silently forcing TLS), SMTP port bounds,
non-destructive credential merge (blank password no longer clobbers the
stored one), GET /config returns port/secure/to/username + hasPassword
(password never returned), full form prefill + keep-hint placeholder.

Verified live pre-fix: send('test')/'deploymentSuccess'/'auto-restart'
all returned 'not enabled'. Post-fix: +20 tests (route + manager),
full suite 2641/2641 (112 suites).

Judge: GLM-5.3 cold read via delegate_task (deleg_ad765d63), grade B,
ship, zero blockers; judge independently ran the DC-092 suites (38/38).
Polish #1 (canonical event for provider titles) folded in. Remaining
emitters with the same gate-miss class (recipeRemoved, workflow,
ssl-cert-expiry, dns-propagation, drift-detected, dependency-restart-*)
noted for a follow-up tick.
2026-08-22 18:17:14 -07:00

348 lines
14 KiB
JavaScript

const express = require('express');
const { validateURL, validateToken } = require('../src/security/input-validator');
const validatorLib = require('validator');
const { paginate, parsePaginationParams } = require('../src/utilities/pagination');
const { ValidationError } = require('../src/utilities/errors');
const { ok, successMessage } = require('../src/utils/responses');
/**
* Notifications route factory
* @param {Object} deps - Explicit dependencies
* @param {Object} deps.notification - Notification manager
* @param {Function} deps.asyncHandler - Async route handler wrapper
* @param {Function} deps.ok - Success response helper
* @returns {express.Router}
*/
module.exports = function({ notification, asyncHandler, ok }) {
const router = express.Router();
// GET /config — Get notification configuration (sensitive data redacted)
router.get('/config', asyncHandler(async (req, res) => {
const notificationConfig = notification.getConfig();
// Return config without sensitive data
const safeConfig = {
enabled: notificationConfig.enabled,
providers: {
discord: {
enabled: notificationConfig.providers.discord?.enabled || false,
configured: !!notificationConfig.providers.discord?.webhookUrl
},
telegram: {
enabled: notificationConfig.providers.telegram?.enabled || false,
configured: !!(notificationConfig.providers.telegram?.botToken && notificationConfig.providers.telegram?.chatId)
},
ntfy: {
enabled: notificationConfig.providers.ntfy?.enabled || false,
configured: !!notificationConfig.providers.ntfy?.topic,
serverUrl: notificationConfig.providers.ntfy?.serverUrl || 'https://ntfy.sh'
},
email: {
enabled: notificationConfig.providers.email?.enabled || false,
configured: !!(notificationConfig.providers.email?.host && notificationConfig.providers.email?.to),
host: notificationConfig.providers.email?.host || '',
from: notificationConfig.providers.email?.from || '',
// DC-092: the settings UI needs these to roundtrip the form.
// Password is NEVER returned; hasPassword lets the UI show a
// "leave blank to keep" hint instead of an empty-looking field.
port: notificationConfig.providers.email?.port || 587,
secure: notificationConfig.providers.email?.secure === true,
to: notificationConfig.providers.email?.to || '',
username: notificationConfig.providers.email?.username || '',
hasPassword: !!notificationConfig.providers.email?.password
}
},
events: notificationConfig.events,
healthCheck: notificationConfig.healthCheck
};
ok(res, { config: safeConfig });
}, 'notifications-config-get'));
// POST /config — Update notification configuration
router.post('/config', asyncHandler(async (req, res) => {
const { enabled, providers, events, healthCheck } = req.body;
const notificationConfig = notification.getConfig();
// DC-092: clients have historically sent at least three field spellings:
// the settings UI sends email.user/email.pass (its input ids are
// email-user/email-pass) while the manager/route read username/password.
// Normalize aliases onto the canonical keys BEFORE the merge so SMTP auth
// actually applies for UI-saved configs.
if (providers?.email) {
if (providers.email.user !== undefined && providers.email.username === undefined) {
providers.email.username = providers.email.user;
}
if (providers.email.pass !== undefined && providers.email.password === undefined) {
providers.email.password = providers.email.pass;
}
delete providers.email.user;
delete providers.email.pass;
}
// DC-092 strict boolean contract: enabled/secure must be actual
// booleans. `"false"` (string) is truthy — !!"false" === true — and
// previously persisted as-is, silently forcing TLS on the next send.
// Reject instead of coercing.
const boolOrThrow = (val, label) => {
if (val === undefined) return;
if (typeof val !== 'boolean') {
throw new ValidationError(`${label} must be a boolean (got ${typeof val})`);
}
};
boolOrThrow(enabled, 'enabled');
boolOrThrow(providers?.discord?.enabled, 'providers.discord.enabled');
boolOrThrow(providers?.telegram?.enabled, 'providers.telegram.enabled');
boolOrThrow(providers?.ntfy?.enabled, 'providers.ntfy.enabled');
boolOrThrow(providers?.email?.enabled, 'providers.email.enabled');
boolOrThrow(providers?.email?.secure, 'providers.email.secure');
// Validate provider webhook URLs and tokens
if (providers) {
if (providers.discord?.webhookUrl) {
try {
validateURL(providers.discord.webhookUrl);
} catch (validationErr) {
throw new ValidationError('Invalid Discord webhook URL');
}
}
if (providers.telegram?.botToken) {
try {
validateToken(providers.telegram.botToken);
} catch (validationErr) {
throw new ValidationError('Invalid Telegram bot token format');
}
}
if (providers.ntfy?.serverUrl) {
try {
validateURL(providers.ntfy.serverUrl);
} catch (validationErr) {
throw new ValidationError('Invalid ntfy server URL');
}
}
if (providers.ntfy?.topic) {
const topicRegex = /^[a-zA-Z0-9_-]{1,64}$/;
if (!topicRegex.test(providers.ntfy.topic)) {
throw new ValidationError('Invalid ntfy topic (alphanumeric, hyphens, underscores only, max 64 chars)');
}
}
if (providers.email?.to) {
const emails = providers.email.to.split(',').map(e => e.trim());
for (const email of emails) {
if (!validatorLib.isEmail(email)) {
throw new ValidationError(`Invalid email address: ${email}`);
}
}
}
if (providers.email?.host && typeof providers.email.host === 'string') {
if (!validatorLib.isFQDN(providers.email.host) && !validatorLib.isIP(providers.email.host)) {
throw new ValidationError('Invalid SMTP host');
}
}
if (providers.email?.port !== undefined) {
const p = Number(providers.email.port);
if (!Number.isInteger(p) || p < 1 || p > 65535) {
throw new ValidationError('SMTP port must be an integer 1-65535');
}
}
}
// Update enabled state
if (typeof enabled === 'boolean') {
notificationConfig.enabled = enabled;
}
// Update providers (only update provided fields)
if (providers) {
if (providers.discord) {
notificationConfig.providers.discord = {
...notificationConfig.providers.discord,
...providers.discord
};
}
if (providers.telegram) {
notificationConfig.providers.telegram = {
...notificationConfig.providers.telegram,
...providers.telegram
};
}
if (providers.ntfy) {
notificationConfig.providers.ntfy = {
...notificationConfig.providers.ntfy,
...providers.ntfy
};
}
if (providers.email) {
// Non-destructive merge: an empty-string username/password from the
// UI (password field is intentionally left blank to keep stored
// credentials) must NOT clobber the stored credential.
const stored = notificationConfig.providers.email;
const incoming = { ...providers.email };
if (incoming.password === '') delete incoming.password;
if (incoming.username === '') delete incoming.username;
notificationConfig.providers.email = {
...stored,
...incoming
};
}
}
// Update events. DC-092: the UI sends camelCase keys (containerDown);
// the canonical store/gate keys are kebab-case (container-down). Fold
// before merging so UI toggles actually reach the keys the send() gate
// reads. Values must be booleans; unknown keys pass through unchanged
// (canonicalized if known alias) and merge over defaults.
if (events) {
const EVENT_KEY_ALIASES = {
containerDown: 'container-down',
containerUp: 'container-up',
deploymentSuccess: 'deploy-success',
deploymentFailed: 'deploy-failed',
deploySuccess: 'deploy-success',
deployFailed: 'deploy-failed',
resourceAlert: 'alert',
updateAvailable: 'update-available',
backupComplete: 'backup-complete',
backupFailed: 'backup-failed',
autoRestart: 'auto-restart',
};
const folded = {};
for (const [k, v] of Object.entries(events)) {
const canonicalKey = EVENT_KEY_ALIASES[k] || k;
folded[canonicalKey] = v;
}
for (const [k, v] of Object.entries(folded)) {
if (typeof v !== 'boolean') {
throw new ValidationError(`events.${k} must be a boolean (got ${typeof v})`);
}
}
notificationConfig.events = { ...notificationConfig.events, ...folded };
}
// Update health check settings
if (healthCheck) {
const wasEnabled = notificationConfig.healthCheck?.enabled;
notificationConfig.healthCheck = { ...notificationConfig.healthCheck, ...healthCheck };
// Restart daemon if settings changed
if (healthCheck.enabled !== wasEnabled || healthCheck.intervalMinutes) {
if (notificationConfig.healthCheck.enabled) {
notification.startHealthDaemon();
} else {
notification.stopHealthDaemon();
}
}
}
await notification.saveConfig();
successMessage(res, 'Notification config updated');
}, 'notifications-config-update'));
// POST /test — Test notification delivery
router.post('/test', asyncHandler(async (req, res) => {
const { provider } = req.body;
if (provider) {
// Test specific provider
let result;
switch (provider) {
case 'discord':
result = await notification.sendDiscord('Test Notification', 'This is a test notification from DashCaddy.', 'info');
break;
case 'telegram':
result = await notification.sendTelegram('Test Notification', 'This is a test notification from DashCaddy.', 'info');
break;
case 'ntfy':
result = await notification.sendNtfy('Test Notification', 'This is a test notification from DashCaddy.', 'info');
break;
case 'email':
result = await notification.sendEmail('Test Notification', 'This is a test notification from DashCaddy.', 'info');
break;
default:
throw new ValidationError('Unknown provider');
}
// result.success reflects actual delivery; keep that semantic by using
// res.json directly (ok() hardcodes success:true).
res.json({ success: result.success, provider, error: result.error });
} else {
// Test all enabled providers
const result = await notification.send('test', 'Test Notification', 'This is a test notification from DashCaddy.', 'info');
ok(res, { ...result });
}
}, 'notifications-test'));
// GET /history — Get notification history
router.get('/history', asyncHandler(async (req, res) => {
const notificationHistory = notification.getHistory();
const paginationParams = parsePaginationParams(req.query);
if (paginationParams) {
const result = paginate(notificationHistory, paginationParams);
ok(res, { history: result.data, total: notificationHistory.length, pagination: result.pagination });
} else {
const limit = parseInt(req.query.limit) || 50;
ok(res, {
history: notificationHistory.slice(0, limit),
total: notificationHistory.length
});
}
}, 'notifications-history'));
// DELETE /history — Clear notification history
router.delete('/history', asyncHandler(async (req, res) => {
notification.clearHistory();
successMessage(res, 'Notification history cleared');
}, 'notifications-history-clear'));
// POST /health-check — Manually trigger health check
router.post('/health-check', asyncHandler(async (req, res) => {
await notification.checkHealth();
const notificationConfig = notification.getConfig();
ok(res, {
lastCheck: notificationConfig.healthCheck.lastCheck,
containersMonitored: Object.keys(notification.getHealthState()).length
});
}, 'notifications-health-check'));
// GET /status — Get notification system status
router.get('/status', asyncHandler(async (req, res) => {
const notificationConfig = notification.getConfig();
const providers = notificationConfig.providers || {};
ok(res, {
enabled: notificationConfig.enabled,
providers: {
discord: providers.discord?.enabled && !!providers.discord?.webhookUrl,
telegram: providers.telegram?.enabled && !!providers.telegram?.botToken && !!providers.telegram?.chatId,
ntfy: providers.ntfy?.enabled && !!providers.ntfy?.topic,
email: providers.email?.enabled && !!providers.email?.host && !!providers.email?.to
},
lastSent: notification.lastSent,
healthCheck: notificationConfig.healthCheck?.enabled ? {
enabled: true,
lastCheck: notificationConfig.healthCheck.lastCheck,
intervalMinutes: notificationConfig.healthCheck.intervalMinutes
} : { enabled: false }
});
}, 'notifications-status'));
// POST /send — Manual test send (used by frontend "Send Test" button)
router.post('/send', asyncHandler(async (req, res) => {
const { event, data, type } = req.body;
if (!event) {
throw new ValidationError('Event type is required');
}
// Use 'test' as the event for manual sends
const result = await notification.send(event, data || {}, type || 'info');
// result.success reflects actual per-provider delivery; ok() hardcodes true,
// so use res.json to preserve the partial-failure semantic.
res.json({
success: result.success,
event,
results: result.results
});
}, 'notifications-send'));
return router;
};