[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.
This commit is contained in:
Hermes
2026-08-22 18:16:47 -07:00
parent df55677bd1
commit 1328cfda6b
7 changed files with 620 additions and 92 deletions
@@ -7,6 +7,28 @@ const fs = require('fs');
const path = require('path');
const nodemailer = require('nodemailer');
// Canonical event names are kebab-case ('container-down'). Emitters and the
// settings UI historically send camelCase ('containerDown', 'deploymentSuccess')
// and the alias map below folds every known spelling onto the canonical key.
// DC-092: before this map, the events gate looked up the RAW event name, so
// 'deploymentSuccess' (routes/apps/deploy.js, routes/recipes/deploy.js) and
// 'auto-restart' (resource-monitor.js) were absent from DEFAULT events and
// silently dropped, and UI camelCase toggles never reached the kebab keys the
// gate reads — the toggles were cosmetic.
const EVENT_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 DEFAULT_CONFIG = {
enabled: true,
providers: {
@@ -21,7 +43,13 @@ const DEFAULT_CONFIG = {
'alert': true,
'backup-complete': true,
'backup-failed': true,
'update-available': true
'update-available': true,
// DC-092: emitters (apps/recipes deploy routes) fire these; they were
// missing from defaults entirely, so every deploy notification was
// silently dropped before this fix.
'deploy-success': true,
'deploy-failed': true,
'auto-restart': true
}
};
@@ -48,6 +76,7 @@ class NotificationManager extends EventEmitter {
try {
if (fs.existsSync(this.NOTIFICATIONS_FILE)) {
const data = JSON.parse(fs.readFileSync(this.NOTIFICATIONS_FILE, 'utf8'));
this._canonicalizeLegacyKeys(data);
this.config = this._mergeConfig(DEFAULT_CONFIG, data);
}
} catch (error) {
@@ -55,6 +84,40 @@ class NotificationManager extends EventEmitter {
}
}
/**
* DC-092: configs saved by older clients may contain the legacy spellings
* the old POST /config merged verbatim — email.user/email.pass instead of
* username/password, and camelCase event keys instead of kebab-case. Fold
* them onto the canonical keys BEFORE the defaults merge (after the merge
* the canonical keys always exist from defaults, so the alias guards would
* never fire) so a config file written before this fix keeps working: SMTP
* auth applies and event toggles gate correctly.
*/
_canonicalizeLegacyKeys(data) {
// Email credentials: user/pass → username/password (only when the
// canonical key is absent in the raw data; canonical wins on conflict).
const email = data?.providers?.email;
if (email && typeof email === 'object') {
if (email.user !== undefined && email.username === undefined) email.username = email.user;
if (email.pass !== undefined && email.password === undefined) email.password = email.pass;
delete email.user;
delete email.pass;
// secure must be a real boolean: legacy string values (e.g. "false"
// from hand-edited JSON) are truthy under !! and would force TLS.
if (email.secure !== undefined) email.secure = email.secure === true;
}
// Event keys: camelCase → kebab-case canonical.
if (data?.events && typeof data.events === 'object') {
for (const [k, v] of Object.entries(data.events)) {
const canonicalKey = EVENT_ALIASES[k];
if (canonicalKey) {
if (data.events[canonicalKey] === undefined) data.events[canonicalKey] = v;
delete data.events[k];
}
}
}
}
/**
* Merge loaded config with defaults
*/
@@ -130,9 +193,15 @@ class NotificationManager extends EventEmitter {
return { success: false, error: 'Notifications disabled' };
}
// Check if event is enabled
if (event && this.config.events && !this.config.events[event]) {
return { success: false, error: `Event ${event} not enabled` };
// Fold legacy/camelCase spellings onto canonical kebab-case keys (DC-092).
const canonical = EVENT_ALIASES[event] || event;
// Check if event is enabled. 'test' bypasses the gate: it is the settings
// UI "Send Test" flow and is not an operator-togglable event (there is no
// 'test' key in events; gating on it made the Test button a no-op).
const gated = canonical !== 'test';
if (gated && this.config.events && this.config.events[canonical] !== true) {
return { success: false, error: `Event ${canonical} not enabled` };
}
const results = [];
@@ -141,7 +210,7 @@ class NotificationManager extends EventEmitter {
// Discord
if (providers.discord?.enabled && providers.discord?.webhookUrl) {
try {
const result = await this.sendDiscord(this._formatText(data, event), this._formatEmbed(data, event, type));
const result = await this.sendDiscord(this._formatText(data, canonical), this._formatEmbed(data, canonical, type));
results.push({ provider: 'discord', ...result });
} catch (error) {
results.push({ provider: 'discord', success: false, error: error.message });
@@ -151,7 +220,7 @@ class NotificationManager extends EventEmitter {
// Telegram
if (providers.telegram?.enabled && providers.telegram?.botToken && providers.telegram?.chatId) {
try {
const result = await this.sendTelegram(this._formatText(data, event));
const result = await this.sendTelegram(this._formatText(data, canonical));
results.push({ provider: 'telegram', ...result });
} catch (error) {
results.push({ provider: 'telegram', success: false, error: error.message });
@@ -161,7 +230,7 @@ class NotificationManager extends EventEmitter {
// ntfy
if (providers.ntfy?.enabled && providers.ntfy?.topic) {
try {
const result = await this.sendNtfy(this._formatText(data, event), this._formatTitle(event));
const result = await this.sendNtfy(this._formatText(data, canonical), this._formatTitle(canonical));
results.push({ provider: 'ntfy', ...result });
} catch (error) {
results.push({ provider: 'ntfy', success: false, error: error.message });
@@ -172,8 +241,8 @@ class NotificationManager extends EventEmitter {
if (providers.email?.enabled && providers.email?.host && providers.email?.to) {
try {
const result = await this.sendEmail(
this._formatTitle(event),
this._formatText(data, event)
this._formatTitle(canonical),
this._formatText(data, canonical)
);
results.push({ provider: 'email', ...result });
} catch (error) {
@@ -183,9 +252,9 @@ class NotificationManager extends EventEmitter {
const allSucceeded = results.every(r => r.success);
this._addToHistory({
title: this._formatTitle(event),
title: this._formatTitle(canonical),
type,
event,
event: canonical,
results
});
@@ -290,7 +359,7 @@ class NotificationManager extends EventEmitter {
const transporter = nodemailer.createTransport({
host,
port: parseInt(port) || 587,
secure: !!secure,
secure: secure === true,
auth: username ? {
user: username,
pass: password