[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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user