Compare commits

..
Author SHA1 Message Date
Hermes 1328cfda6b [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:16:47 -07:00
Hermes df55677bd1 Merge DC-091: config-schema KNOWN_KEYS licenseBackup/_version fix [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
2026-08-22 17:22:20 -07:00
Hermes ddbea0a040 [glm-grade=A] fix(config): teach schema KNOWN_KEYS the licenseBackup/_version writer keys (DC-091)
Every startup logged two false-positive 'Unknown config key — possible typo?'
warns: licenseBackup (written by src/managers/license-manager.js:510 activation
persistence) and _version (stamped by src/config/migrations.js). Both are
first-party writers the validator was never taught about (DC-091).

- config-schema.js: add both keys to KNOWN_KEYS with a source-of-writes comment
- config-schema.test.js (new): 5 regression tests — live production config keyset
  validates with zero unknown-key warnings, writer keys never warn, genuine
  typos still warn (exact string), license/licenseBackup sync guard, _version
  recognized at every migration value

Verified: full jest suite 111 suites / 2621 tests green (baseline 110/2616).
Warns reproduced in live container logs 2026-08-22T23:53:54Z; live config.json
contains both keys (licenseBackup activation, _version 2).

Judge: GLM-5.3 cold read via delegate_task (deleg_30e52384, 36s) — grade A, ship.
Verdict URN: urn:ump:ermkvz6ifbp5svga5cnapv5jhm7c5b7qdbwjjerfpxbwcrolm2za (readback verified)
Codex quota-walled until 2026-08-29; GLM stand-in per Sami 2026-08-17 directive.
2026-08-22 17:22:13 -07:00
Hermes 1d1cd5c95e Merge dc/DC-090-incident-hysteresis-parity: outage incidents follow displayed hysteresis status [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
2026-08-22 16:53:02 -07:00
Hermes 88f1d4a414 [glm-grade=A] fix(monitoring): DC-090 outage incidents follow displayed hysteresis status
checkForIncidents compared raw probe transitions while the dashboard badge
(DC-086) follows post-hysteresis displayed status. A single raw down blip
between two ups opened AND resolved a critical outage incident; a suppressed
up blip during a real outage resolved it early. Incidents now open/resolve
on displayed-vs-displayed transitions; previousDisplayed=null keeps legacy
raw semantics for direct callers. 6 new parity tests + legacy checkService
test moved to a 4-probe chain. Suite 2616/2616 (110).

Verdict: urn:ump:yc5rdlnmnmhch5audc5fifgbt6d7moi2stqfs5vidsbh6x6zkvgq
2026-08-22 16:52:53 -07:00
Hermes dd1110ef52 Merge dc/DC-089-invite-log-pii-masking: mask invite/user emails in server logs [glm-grade=B]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
2026-08-22 16:23:06 -07:00
Hermes 6732a1e1df [glm-grade=B] fix(auth): DC-089 mask invite/user emails in server logs
Two log sites in routes/auth/admin.js wrote raw email PII to the server
log: the SMTP-unconfigured 'auth-invite-send' warn and the 'invite
accepted, user created' info. Both now route through
AuthProvider.maskEmail() with a '[unmaskable-email]' sentinel fallback
(never the raw address). Two regression tests assert the raw address is
absent from log meta and the masked form present. Response contract
unchanged (full email still returned to the authenticated admin).

Judge: GLM-5.3 cold read (deleg_f0896de3), grade B / ship / zero
blockers; polish notes folded in. Verdict URN:
urn:ump:jd2htpwq76ni6bapj3vxjpvypfdnoc4argqbzpcerutmh7u5khea
Full suite: 2610/2610 (109 suites).
2026-08-22 16:22:53 -07:00
Hermes ea96abe95a Merge dc/DC-088-remove-service-tombstones: removeService generation tombstones + incident closure [glm-grade=B]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
2026-08-22 15:55:29 -07:00
Hermes 3ccf66754a [glm-grade=B] fix(monitoring): DC-088 removeService generation tombstones + incident closure
- serviceGenerations no longer leaks entries: removeService deletes the live
  entry and records a TTL'd (10min) tombstone swept by cleanupHistory
- monotonic instance-wide generationSeq prevents generation reuse across
  remove->re-add cycles (ABA) and supersedes tombstones on re-configure
- _isStaleCapture(): presence-aware stale check — live entry must match
  exactly; no entry is stale only under a higher-generation tombstone
  (preserves correct behavior for disk-loaded never-configured services)
- catch path increments consecutiveFailures only after the stale check, so
  a late-rejected probe cannot resurrect state for a removed service
- open incidents for a removed service close via the standard resolve path
  (resolvedBy=service-removed, WS/SSE incident-resolved broadcast)
- 6 regression tests; full suite 2608/2608 green

Judge: GLM-5.3 cold read (Codex stand-in), verdict B/ship, zero blockers
2026-08-22 15:55:26 -07:00
Hermes c71b794ccc Merge dc/DC-087-test-mirrors-fetcht: hermetic caddy-admin test mirrors + raw-fetch guard [glm-grade=B]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
2026-08-22 15:14:35 -07:00
14 changed files with 1147 additions and 109 deletions
+46 -1
View File
@@ -237,4 +237,49 @@ describe('DC-085: link-first admin invites', () => {
.send({ email: 'a@x.com', ttlHours: 1 });
expect(res.body.shareText).toContain('expires in 1h');
});
});
test('DC-089: SMTP-unconfigured warn log masks the invite email (no raw PII)', async () => {
mockEmailSender.isConfigured.mockReturnValueOnce(false);
const res = await request(app)
.post('/api/v1/auth/admin/invites')
.send({ email: 'friend@example.com', role: 'operator', sendEmail: true });
expect(res.status).toBe(200);
expect(res.body.deliveredVia).toBe('failed');
const warn = logCalls.find(c =>
c.level === 'warn' && c.topic === 'auth-invite-send'
);
expect(warn).toBeDefined();
// The raw address must not appear; the masked form must.
expect(JSON.stringify(warn.meta)).not.toContain('friend@example.com');
expect(warn.meta.email).toBe('fr****@example.com');
});
test('DC-089: invite-accepted info log masks the created user email (no raw PII)', async () => {
// Pre-authorize the email (POST /admin/users) so userStore.login doesn't
// reject with not_authorized — bootstrap already happened in beforeEach.
const preauth = await request(app)
.post('/api/v1/auth/admin/users')
.send({ email: 'newfriend@example.com' });
expect(preauth.status).toBe(200);
const issue = await request(app)
.post('/api/v1/auth/admin/invites')
.send({ email: 'newfriend@example.com', role: 'viewer' });
expect(issue.status).toBe(200);
const token = issue.body.acceptUrl.match(/invites\/([^/]+)\/accept/)[1];
const res = await request(app)
.post(`/api/v1/auth/invites/${token}/accept`)
.send({});
expect(res.status).toBe(200);
const info = logCalls.find(c =>
c.level === 'info' && c.msg === 'invite accepted, user created'
);
expect(info).toBeDefined();
expect(JSON.stringify(info.meta)).not.toContain('newfriend@example.com');
expect(info.meta.email).toBe('ne****@example.com');
});
});
@@ -0,0 +1,72 @@
'use strict';
/**
* Regression tests for config-schema.js KNOWN_KEYS — DC-091.
*
* Bug: license-manager.js persists config.licenseBackup (activation
* restore-on-restart) and src/config/migrations.js stamps config._version,
* but neither key was in KNOWN_KEYS — so every startup logged
* `Unknown config key "licenseBackup" / "_version" — possible typo?`
* false positives (verified in live dashcaddy-api container logs,
* 2026-08-22T23:53:54Z restart).
*
* These tests pin: (1) the live production config key set validates with
* zero unknown-key warnings, (2) genuine typos still warn, (3) the schema
* stays in sync with the first-party writer keys.
*/
const { validateConfig } = require('../src/utilities/config-schema');
describe('config-schema KNOWN_KEYS vs first-party writers (DC-091)', () => {
// Exact key set of the live production config.json (DNS2, verified
// 2026-08-23). If a new key appears here, teach KNOWN_KEYS about it —
// or fix the writer if it's a typo.
const LIVE_CONFIG_KEYS = [
'_version', 'configurationType', 'customFavicon', 'customLogo',
'dashboardHost', 'dashboardTitle', 'dns', 'dnsServers', 'language',
'license', 'licenseBackup', 'logoPosition', 'pylon', 'setupComplete',
'timestamp', 'tld', 'updatedAt'
];
test('live production config key set produces zero unknown-key warnings', () => {
const config = {};
for (const key of LIVE_CONFIG_KEYS) {
// Minimal valid-ish values; validateConfig only cares about shape
// for these keys, and unknown-key detection is the target here.
config[key] = key === '_version' ? 2 : (key === 'dnsServers' ? {} : 'x');
}
const result = validateConfig(config);
const unknownWarnings = result.warnings.filter((w) => w.includes('Unknown config key'));
expect(unknownWarnings).toEqual([]);
});
test('licenseBackup and _version (first-party writer keys) do not warn', () => {
const result = validateConfig({ licenseBackup: { code: 'DC-...' }, _version: 2 });
expect(result.warnings).toEqual([]);
});
test('genuine typos still warn (guard against over-allowing)', () => {
const result = validateConfig({ dashboadTitle: 'typo' });
expect(result.warnings).toEqual([
'Unknown config key "dashboadTitle" — possible typo?'
]);
});
test('KNOWN_KEYS stays in sync with license-manager writer keys', () => {
// license-manager writes config.licenseBackup and config.license — both
// must be recognized. We assert via validateConfig (public surface)
// rather than importing the private KNOWN_KEYS array.
const result = validateConfig({ license: { code: 'DC-...' }, licenseBackup: { code: 'DC-...' } });
expect(result.warnings.filter((w) => w.includes('Unknown config key'))).toEqual([]);
});
});
describe('config-schema sync guard: migrations writer', () => {
test('_version is recognized at every migration version value', () => {
// migrations.js bumps _version 0→1→2; the key itself must never warn.
for (const v of [0, 1, 2, 99]) {
const result = validateConfig({ _version: v });
expect(result.warnings).toEqual([]);
}
});
});
@@ -0,0 +1,186 @@
/**
* Tests for DC-090: outage incidents follow the DISPLAYED (post-hysteresis)
* status — the same signal that flips the dashboard badge.
*
* - A single raw "down" blip that hysteresis suppresses opens NO outage
* incident (the DC-089-noted raw-transition bug).
* - A suppressed blip does not resolve a real open outage (UP_THRESHOLD=2).
* - DOWN_THRESHOLD consecutive downs open exactly ONE outage incident.
* - The incident payload carries the displayed snapshot, not the raw probe.
* - Direct callers without hysteresis state keep legacy raw semantics.
*
* The probe() helper replicates checkService's exact call order: capture the
* pre-probe raw + displayed state, recordStatus (updates both maps), then
* checkForIncidents with both previous states.
*/
'use strict';
const path = require('path');
const fs = require('fs');
const os = require('os');
// Use an isolated data dir so test history doesn't pollute the real one.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-incpar-'));
process.env.HEALTH_DATA_DIR = tmpDir;
process.env.HEALTH_CONFIG_FILE = path.join(tmpDir, 'health-config.json');
process.env.HEALTH_HISTORY_FILE = path.join(tmpDir, 'health-history.json');
// Module exports a singleton instance, not a class. Reset per-test state by
// replacing the relevant maps on the singleton in beforeEach.
const healthCheckerSingleton = require('../src/monitoring/health-checker');
const originalDownThreshold = process.env.HEALTH_DOWN_THRESHOLD;
const originalUpThreshold = process.env.HEALTH_UP_THRESHOLD;
function restoreEnv(name, value) {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
function makeUp(serviceId = 'svc1') {
return {
serviceId,
timestamp: new Date().toISOString(),
status: 'up',
responseTime: 50,
statusCode: 200,
message: 'Service is healthy',
details: { headers: {}, bodyLength: 12 }
};
}
function makeDown(serviceId = 'svc1') {
return {
serviceId,
timestamp: new Date().toISOString(),
status: 'down',
responseTime: 50,
statusCode: 500,
message: 'fail',
details: { headers: {}, bodyLength: 0 }
};
}
describe('DC-090: outage incidents follow the displayed (hysteresis) status', () => {
let hc;
let incidentCreatedSpy;
let incidentResolvedSpy;
beforeEach(() => {
healthCheckerSingleton.displayedStatus = new Map();
healthCheckerSingleton.consecutiveSinceChange = new Map();
healthCheckerSingleton.currentStatus = new Map();
healthCheckerSingleton.history = {};
healthCheckerSingleton.incidents = [];
healthCheckerSingleton.removeAllListeners('incident-created');
healthCheckerSingleton.removeAllListeners('incident-resolved');
incidentCreatedSpy = jest.fn();
incidentResolvedSpy = jest.fn();
healthCheckerSingleton.on('incident-created', incidentCreatedSpy);
healthCheckerSingleton.on('incident-resolved', incidentResolvedSpy);
hc = healthCheckerSingleton;
});
afterEach(() => {
restoreEnv('HEALTH_DOWN_THRESHOLD', originalDownThreshold);
restoreEnv('HEALTH_UP_THRESHOLD', originalUpThreshold);
});
afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
// Replicates checkService's record+incident sequence for one raw probe.
function probe(status, config = {}) {
const previousStatus = hc.currentStatus.get(status.serviceId);
const previousDisplayed = hc.displayedStatus.get(status.serviceId) || null;
hc.recordStatus(status.serviceId, status);
hc.checkForIncidents(status.serviceId, status, config, previousStatus, previousDisplayed);
}
test('a single down blip between two ups opens NO outage incident', () => {
probe(makeUp()); // baseline: displayed up
probe(makeDown()); // blip — hysteresis keeps displayed up
probe(makeUp()); // recovered
expect(hc.incidents).toHaveLength(0);
expect(incidentCreatedSpy).not.toHaveBeenCalled();
});
test('DOWN_THRESHOLD consecutive downs open exactly one outage incident (critical)', () => {
probe(makeUp());
probe(makeDown()); // counter=1, displayed still up
probe(makeDown()); // counter=2 → displayed flips down → incident
expect(hc.incidents).toHaveLength(1);
const incident = hc.incidents[0];
expect(incident.type).toBe('outage');
expect(incident.severity).toBe('critical');
expect(incident.status).toBe('open');
expect(incidentCreatedSpy).toHaveBeenCalledTimes(1);
probe(makeDown()); // still down — no new transition, no second incident
expect(hc.incidents).toHaveLength(1);
expect(incident.occurrences).toBe(1); // occurrences count displayed flips, not raw probes
expect(incidentCreatedSpy).toHaveBeenCalledTimes(1);
});
test('the outage incident payload carries the displayed snapshot, not the raw blip', () => {
probe(makeUp());
const blip = makeDown();
blip.statusCode = 599;
probe(blip); // suppressed blip — must not appear in any incident
probe(makeDown()); // flip
expect(hc.incidents).toHaveLength(1);
// The incident's details snapshot is the probe that FLIPPED the displayed
// state (the second down), not the earlier suppressed blip.
expect(hc.incidents[0].details.statusCode).not.toBe(599);
});
test('a suppressed up blip does not resolve a real open outage (UP_THRESHOLD=2)', () => {
process.env.HEALTH_UP_THRESHOLD = '2';
jest.resetModules();
const hc2 = require('../src/monitoring/health-checker');
hc2.displayedStatus = new Map();
hc2.consecutiveSinceChange = new Map();
hc2.currentStatus = new Map();
hc2.history = {};
hc2.incidents = [];
hc2.removeAllListeners('incident-created');
hc2.removeAllListeners('incident-resolved');
const p2 = (status) => {
const prevRaw = hc2.currentStatus.get(status.serviceId);
const prevDisp = hc2.displayedStatus.get(status.serviceId) || null;
hc2.recordStatus(status.serviceId, status);
hc2.checkForIncidents(status.serviceId, status, {}, prevRaw, prevDisp);
};
p2(makeUp());
p2(makeDown());
p2(makeDown()); // displayed down → outage opens
expect(hc2.incidents).toHaveLength(1);
expect(hc2.incidents[0].status).toBe('open');
p2(makeUp()); // counter=1 < UP_THRESHOLD=2 → displayed still down
expect(hc2.displayedStatus.get('svc1').status).toBe('down');
expect(hc2.incidents[0].status).toBe('open'); // NOT resolved by the blip
p2(makeUp()); // counter=2 → displayed up → incident resolves
expect(hc2.displayedStatus.get('svc1').status).toBe('up');
expect(hc2.incidents[0].status).toBe('resolved');
});
test('legacy direct callers (no displayed state) keep raw transition semantics', () => {
hc.currentStatus.set('svc1', { status: 'up' });
const status = { status: 'down', timestamp: new Date().toISOString(), responseTime: 100 };
hc.checkForIncidents('svc1', status, {}); // 4-arg call, no previousDisplayed
expect(hc.incidents).toHaveLength(1);
expect(hc.incidents[0].type).toBe('outage');
});
test('slow-response detection still fires per-probe regardless of hysteresis', () => {
const slowUp = makeUp();
slowUp.responseTime = 6000;
probe(slowUp, { slowResponseThreshold: 5000 });
expect(hc.incidents.some(i => i.type === 'slow-response')).toBe(true);
});
});
+115 -1
View File
@@ -204,15 +204,22 @@ describe('HealthChecker', () => {
});
it('opens and resolves an outage incident across real checkService transitions', async () => {
// DC-090: incidents follow the DISPLAYED (post-hysteresis) status.
// DOWN_THRESHOLD defaults to 2, so it takes two consecutive failed
// probes to flip displayed down and open the outage; one up probe
// (UP_THRESHOLD=1) resolves it.
healthChecker._doRequest = jest.fn()
.mockResolvedValueOnce({ healthy: true, statusCode: 200, message: 'ok', details: {} })
.mockResolvedValueOnce({ healthy: false, statusCode: 500, message: 'down', details: {} })
.mockResolvedValueOnce({ healthy: false, statusCode: 500, message: 'down', details: {} })
.mockResolvedValueOnce({ healthy: true, statusCode: 200, message: 'ok', details: {} });
const config = { url: 'http://test.local' };
await healthChecker.checkService('svc1', config);
await healthChecker.checkService('svc1', config);
expect(healthChecker.incidents).toHaveLength(0); // one down alone: suppressed blip
await healthChecker.checkService('svc1', config); // second down flips displayed → open
expect(healthChecker.incidents).toHaveLength(1);
expect(healthChecker.incidents[0]).toMatchObject({
serviceId: 'svc1',
@@ -220,7 +227,7 @@ describe('HealthChecker', () => {
status: 'open'
});
await healthChecker.checkService('svc1', config);
await healthChecker.checkService('svc1', config); // up resolves
expect(healthChecker.incidents[0].status).toBe('resolved');
expect(healthChecker.incidents[0].resolvedAt).toBeDefined();
});
@@ -591,6 +598,113 @@ describe('HealthChecker', () => {
});
});
describe('DC-088: removeService generation tombstones + incident closure', () => {
it('does not leak a serviceGenerations entry and records a tombstone', () => {
healthChecker.configureService('svc1', { url: 'http://test.local' });
expect(healthChecker.serviceGenerations.has('svc1')).toBe(true);
healthChecker.removeService('svc1');
expect(healthChecker.serviceGenerations.has('svc1')).toBe(false);
const tomb = healthChecker.removedGenerations.get('svc1');
expect(tomb).toBeDefined();
expect(tomb.generation).toBeGreaterThan(0);
expect(tomb.removedAt).toBeGreaterThan(0);
});
it('re-added service gets a strictly higher generation (no ABA)', () => {
healthChecker.configureService('svc1', { url: 'http://test.local' });
const gen1 = healthChecker.serviceGenerations.get('svc1');
healthChecker.removeService('svc1');
healthChecker.configureService('svc1', { url: 'http://test.local/v2' });
const gen2 = healthChecker.serviceGenerations.get('svc1');
expect(gen2).toBeGreaterThan(gen1);
expect(healthChecker.removedGenerations.has('svc1')).toBe(false);
});
it('closes open incidents for the removed service as resolved', () => {
healthChecker.saveConfig = jest.fn();
healthChecker.incidents.push({
id: 'incident-test-1',
serviceId: 'svc1',
type: 'outage',
status: 'open',
createdAt: new Date(Date.now() - 60_000).toISOString()
});
healthChecker.incidents.push({
id: 'incident-other',
serviceId: 'svc2',
type: 'outage',
status: 'open',
createdAt: new Date(Date.now() - 60_000).toISOString()
});
const resolvedSpy = jest.fn();
healthChecker.on('incident-resolved', resolvedSpy);
healthChecker.removeService('svc1');
const closed = healthChecker.incidents.find(i => i.id === 'incident-test-1');
expect(closed.status).toBe('resolved');
expect(closed.resolvedBy).toBe('service-removed');
expect(closed.resolvedAt).toBeDefined();
expect(closed.duration).toBeGreaterThan(0);
expect(healthChecker.incidents.find(i => i.id === 'incident-other').status).toBe('open');
expect(resolvedSpy).toHaveBeenCalledTimes(1);
});
it('in-flight probe captured before removal is discarded via tombstone', async () => {
let resolveProbe;
healthChecker.config.services.svc1 = { url: 'http://test.local' };
healthChecker._doRequest = jest.fn(() => new Promise(resolve => {
resolveProbe = resolve;
}));
const pending = healthChecker.checkService('svc1', healthChecker.config.services.svc1);
healthChecker.saveConfig = jest.fn();
healthChecker.removeService('svc1');
resolveProbe({ healthy: true, statusCode: 200, message: 'late', details: {} });
await pending;
expect(healthChecker.currentStatus.has('svc1')).toBe(false);
expect(healthChecker.consecutiveFailures.has('svc1')).toBe(false);
});
it('a rejected in-flight probe after removal does not re-create failure state', async () => {
let rejectProbe;
healthChecker.config.services.svc1 = { url: 'http://test.local' };
healthChecker._doRequest = jest.fn(() => new Promise((resolve, reject) => {
rejectProbe = reject;
}));
const pending = healthChecker.checkService('svc1', healthChecker.config.services.svc1);
healthChecker.saveConfig = jest.fn();
healthChecker.removeService('svc1');
rejectProbe(new Error('late failure'));
await pending;
expect(healthChecker.consecutiveFailures.has('svc1')).toBe(false);
expect(healthChecker.currentStatus.has('svc1')).toBe(false);
});
it('sweeps expired tombstones in cleanupHistory', () => {
healthChecker.removedGenerations.set('svc1', {
generation: 1,
removedAt: Date.now() - 60 * 60 * 1000 // 1h ago, TTL default 10m
});
healthChecker.removedGenerations.set('svc2', {
generation: 2,
removedAt: Date.now() // fresh
});
healthChecker.cleanupHistory();
expect(healthChecker.removedGenerations.has('svc1')).toBe(false);
expect(healthChecker.removedGenerations.has('svc2')).toBe(true);
});
});
describe('cleanupHistory', () => {
it('removes entries older than retention period', () => {
const old = new Date(Date.now() - 35 * 24 * 60 * 60 * 1000).toISOString(); // 35 days ago
@@ -214,4 +214,99 @@ describe('NotificationManager', () => {
nm.stopHealthDaemon();
expect(nm.healthDaemonInterval).toBeNull();
});
// ── DC-092: event alias folding + legacy config canonicalization ──────────
test('DC-092: send() folds camelCase aliases onto canonical kebab keys', async () => {
// deploymentSuccess (emitted by routes/apps/deploy.js) previously hit a
// gate miss (no such key in events) and the notification was dropped.
const result = await nm.send('deploymentSuccess', { text: 'deployed' });
expect(result.error).toBeUndefined();
expect(nm.getHistory()[0].event).toBe('deploy-success');
});
test('DC-092: send() accepts the canonical kebab spelling too', async () => {
const result = await nm.send('deploy-success', { text: 'deployed' });
expect(result.error).toBeUndefined();
expect(nm.getHistory()[0].event).toBe('deploy-success');
});
test("DC-092: send('test') bypasses the events gate (Test button works)", async () => {
const result = await nm.send('test', { text: 'Test Notification' });
// No providers are enabled in the default config, so results is empty —
// but the gate must NOT return 'Event test not enabled' like it used to.
expect(result.error).toBeUndefined();
expect(nm.getHistory()[0].event).toBe('test');
});
test('DC-092: send() still gates unknown and disabled events', async () => {
const unknown = await nm.send('some-unknown-event', { text: 'x' });
expect(unknown.success).toBe(false);
expect(unknown.error).toMatch(/not enabled/i);
nm.config.events['container-down'] = false;
const disabled = await nm.send('container-down', { text: 'x' });
expect(disabled.success).toBe(false);
expect(disabled.error).toMatch(/not enabled/i);
});
test('DC-092: DEFAULT_CONFIG includes deploy/auto-restart events', () => {
// Regression pin: these were absent entirely, so deploy notifications
// were dropped for every install regardless of UI toggles.
expect(nm.config.events['deploy-success']).toBe(true);
expect(nm.config.events['deploy-failed']).toBe(true);
expect(nm.config.events['auto-restart']).toBe(true);
});
test('DC-092: legacy config with user/pass and camelCase events canonicalizes on load', () => {
fs.existsSync.mockReturnValue(true);
fs.readFileSync.mockReturnValue(JSON.stringify({
enabled: true,
providers: {
email: {
enabled: true,
host: 'smtp.test',
port: 465,
secure: 'false', // legacy string — must normalize to boolean false
to: 'me@test',
from: 'from@test',
user: 'legacy-user',
pass: 'legacy-pass',
}
},
events: {
containerDown: false,
deploymentSuccess: false,
}
}));
const loaded = new NotificationManager({
NOTIFICATIONS_FILE: NOTIF_FILE,
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
});
const email = loaded.getConfig().providers.email;
expect(email.username).toBe('legacy-user');
expect(email.password).toBe('legacy-pass');
expect(email.user).toBeUndefined();
expect(email.pass).toBeUndefined();
expect(email.secure).toBe(false);
const events = loaded.getConfig().events;
expect(events['container-down']).toBe(false);
expect(events['deploy-success']).toBe(false);
expect(events.containerDown).toBeUndefined();
expect(events.deploymentSuccess).toBeUndefined();
});
test('DC-092: canonical keys win when both spellings exist in a legacy file', () => {
fs.existsSync.mockReturnValue(true);
fs.readFileSync.mockReturnValue(JSON.stringify({
providers: { email: { user: 'legacy', username: 'canonical' } },
events: { containerDown: false, 'container-down': true },
}));
const loaded = new NotificationManager({
NOTIFICATIONS_FILE: NOTIF_FILE,
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
});
expect(loaded.getConfig().providers.email.username).toBe('canonical');
expect(loaded.getConfig().events['container-down']).toBe(true);
});
});
@@ -0,0 +1,267 @@
/**
* DC-092: notifications config contract tests (route level).
*
* The settings UI and the backend drifted apart in three ways, all of which
* made user-facing features silently dead:
* 1. UI sent email.user/email.pass; backend read username/password →
* SMTP auth never applied for UI-saved configs.
* 2. UI sent camelCase event keys (containerDown); the send() gate read
* kebab-case keys (container-down) → event toggles were cosmetic.
* 3. deploy-success/deploy-failed/auto-restart were missing from DEFAULT
* events → deploy + auto-restart notifications always dropped, and
* 'test' was gated too → the Test button was a no-op.
* 4. Non-boolean enabled/secure values (string "false") persisted as-is and
* coerced truthy (!!secure) — silently forcing TLS.
* 5. UI password field roundtrip: GET /config omitted port/secure/to/
* username, and an empty password on save clobbered the stored one.
*
* These tests pin the FIXED contract: alias normalization, strict booleans,
* event-key folding, non-destructive credential merge, redacted GET fields.
*/
'use strict';
const express = require('express');
const request = require('supertest');
// Stub notification manager: in-memory config object, real merge semantics
// are exercised through the route; manager-level canonicalization has its
// own tests in notification-manager.test.js.
function makeStubNotification(initial) {
const nm = {
config: initial,
getConfig() { return this.config; },
async saveConfig() { this.saved = JSON.parse(JSON.stringify(this.config)); return true; },
startHealthDaemon: jest.fn(),
stopHealthDaemon: jest.fn(),
};
return nm;
}
function buildApp(notification) {
const app = express();
app.use(express.json());
const notificationRoutes = require('../../routes/notifications');
app.use('/api/v1/notifications', notificationRoutes({
notification,
asyncHandler: (fn) => (req, res, next) => Promise.resolve(fn(req, res)).catch(next),
ok: (res, data) => res.json({ success: true, ...data }),
}));
// Inline error handler (same pattern as sites-dc074.routes.test.js): maps
// AppError.statusCode to the HTTP status and surfaces err.message.
app.use((err, req, res, next) => {
const status = err.statusCode || 500;
res.status(status).json({
error: err.message || 'Internal Server Error',
code: err.code || null,
});
});
return app;
}
const DEFAULTS = {
enabled: true,
providers: {
discord: { enabled: false, webhookUrl: '' },
telegram: { enabled: false, botToken: '', chatId: '' },
ntfy: { enabled: false, topic: '', serverUrl: 'https://ntfy.sh' },
email: { enabled: false, host: '', port: 587, to: '', from: '', username: '', password: '' },
},
events: {
'container-down': true,
'container-up': false,
'alert': true,
'backup-complete': true,
'backup-failed': true,
'update-available': true,
'deploy-success': true,
'deploy-failed': true,
'auto-restart': true,
},
healthCheck: { enabled: false },
};
function freshConfig() {
return JSON.parse(JSON.stringify(DEFAULTS));
}
describe('DC-092: POST /config field aliases and typing', () => {
test('UI spelling email.user/email.pass normalizes onto username/password', async () => {
const nm = makeStubNotification(freshConfig());
const app = buildApp(nm);
const res = await request(app)
.post('/api/v1/notifications/config')
.send({ providers: { email: { user: 'svc@example.com', pass: 'app-secret' } } });
expect(res.status).toBe(200);
expect(nm.config.providers.email.username).toBe('svc@example.com');
expect(nm.config.providers.email.password).toBe('app-secret');
expect(nm.config.providers.email.user).toBeUndefined();
expect(nm.config.providers.email.pass).toBeUndefined();
});
test('explicit username/password wins over user/pass aliases', async () => {
const nm = makeStubNotification(freshConfig());
const app = buildApp(nm);
await request(app)
.post('/api/v1/notifications/config')
.send({ providers: { email: { user: 'legacy@x.com', pass: 'old', username: 'modern@x.com', password: 'new' } } });
expect(nm.config.providers.email.username).toBe('modern@x.com');
expect(nm.config.providers.email.password).toBe('new');
});
test('string "false" for secure is rejected, not coerced truthy', async () => {
const nm = makeStubNotification(freshConfig());
const app = buildApp(nm);
const res = await request(app)
.post('/api/v1/notifications/config')
.send({ providers: { email: { secure: 'false' } } });
expect(res.status).toBe(400);
expect(nm.config.providers.email.secure).toBeUndefined();
});
test('string enabled for any provider is rejected', async () => {
const nm = makeStubNotification(freshConfig());
const app = buildApp(nm);
for (const prov of ['discord', 'telegram', 'ntfy', 'email']) {
const res = await request(app)
.post('/api/v1/notifications/config')
.send({ providers: { [prov]: { enabled: 'true' } } });
expect(res.status).toBe(400);
}
const top = await request(app)
.post('/api/v1/notifications/config')
.send({ enabled: 'true' });
expect(top.status).toBe(400);
});
test('real booleans pass and persist', async () => {
const nm = makeStubNotification(freshConfig());
const app = buildApp(nm);
const res = await request(app)
.post('/api/v1/notifications/config')
.send({ enabled: false, providers: { email: { secure: true } } });
expect(res.status).toBe(200);
expect(nm.config.enabled).toBe(false);
expect(nm.config.providers.email.secure).toBe(true);
});
test('SMTP port bounds enforced (0, 65536, non-integer rejected)', async () => {
const nm = makeStubNotification(freshConfig());
const app = buildApp(nm);
for (const bad of [0, 65536, 58.5, 'abc']) {
const res = await request(app)
.post('/api/v1/notifications/config')
.send({ providers: { email: { port: bad } } });
expect(res.status).toBe(400);
}
const good = await request(app)
.post('/api/v1/notifications/config')
.send({ providers: { email: { port: 465 } } });
expect(good.status).toBe(200);
expect(nm.config.providers.email.port).toBe(465);
});
});
describe('DC-092: POST /config event-key folding', () => {
test('camelCase event keys fold onto canonical kebab keys', async () => {
const nm = makeStubNotification(freshConfig());
const app = buildApp(nm);
const res = await request(app)
.post('/api/v1/notifications/config')
.send({ events: { containerDown: false, deploymentSuccess: false, resourceAlert: false } });
expect(res.status).toBe(200);
expect(nm.config.events['container-down']).toBe(false);
expect(nm.config.events['deploy-success']).toBe(false);
expect(nm.config.events['alert']).toBe(false);
// legacy camelCase keys must NOT be stored
expect(nm.config.events.containerDown).toBeUndefined();
expect(nm.config.events.deploymentSuccess).toBeUndefined();
expect(nm.config.events.resourceAlert).toBeUndefined();
});
test('canonical kebab keys accepted directly', async () => {
const nm = makeStubNotification(freshConfig());
const app = buildApp(nm);
const res = await request(app)
.post('/api/v1/notifications/config')
.send({ events: { 'container-down': false, 'auto-restart': false } });
expect(res.status).toBe(200);
expect(nm.config.events['container-down']).toBe(false);
expect(nm.config.events['auto-restart']).toBe(false);
});
test('non-boolean event values rejected', async () => {
const nm = makeStubNotification(freshConfig());
const app = buildApp(nm);
const res = await request(app)
.post('/api/v1/notifications/config')
.send({ events: { 'container-down': 'yes' } });
expect(res.status).toBe(400);
});
});
describe('DC-092: POST /config non-destructive credential merge', () => {
test('empty password does not clobber stored password', async () => {
const cfg = freshConfig();
cfg.providers.email.username = 'svc@example.com';
cfg.providers.email.password = 'stored-secret';
const nm = makeStubNotification(cfg);
const app = buildApp(nm);
const res = await request(app)
.post('/api/v1/notifications/config')
.send({ providers: { email: { host: 'smtp.example.com', password: '' } } });
expect(res.status).toBe(200);
expect(nm.config.providers.email.password).toBe('stored-secret');
expect(nm.config.providers.email.host).toBe('smtp.example.com');
});
test('empty username does not clobber stored username', async () => {
const cfg = freshConfig();
cfg.providers.email.username = 'svc@example.com';
const nm = makeStubNotification(cfg);
const app = buildApp(nm);
await request(app)
.post('/api/v1/notifications/config')
.send({ providers: { email: { username: '' } } });
expect(nm.config.providers.email.username).toBe('svc@example.com');
});
test('non-empty password overwrites', async () => {
const cfg = freshConfig();
cfg.providers.email.password = 'old';
const nm = makeStubNotification(cfg);
const app = buildApp(nm);
await request(app)
.post('/api/v1/notifications/config')
.send({ providers: { email: { password: 'rotated' } } });
expect(nm.config.providers.email.password).toBe('rotated');
});
});
describe('DC-092: GET /config redaction and roundtrip fields', () => {
test('returns port/secure/to/username/hasPassword but never the password', async () => {
const cfg = freshConfig();
cfg.providers.email = {
enabled: true,
host: 'smtp.example.com',
port: 465,
secure: true,
to: 'admin@example.com',
from: 'DashCaddy <noreply@example.com>',
username: 'svc@example.com',
password: 'super-secret',
};
const nm = makeStubNotification(cfg);
const app = buildApp(nm);
const res = await request(app).get('/api/v1/notifications/config');
expect(res.status).toBe(200);
const email = res.body.config.providers.email;
expect(email.port).toBe(465);
expect(email.secure).toBe(true);
expect(email.to).toBe('admin@example.com');
expect(email.username).toBe('svc@example.com');
expect(email.hasPassword).toBe(true);
expect(JSON.stringify(res.body)).not.toContain('super-secret');
expect(res.body.config.providers.email.password).toBeUndefined();
});
});
+3 -2
View File
@@ -29,6 +29,7 @@ const platformPaths = require('../../platform-paths');
const { createUserStore } = require('../../src/security/user-store');
const { createInviteStore } = require('../../src/security/invite-store');
const emailSender = require('../../src/auth/providers/email-sender');
const AuthProvider = require('../../src/auth/providers/base');
const { ValidationError, NotFoundError, ForbiddenError, PaymentRequiredError } = require('../../src/utilities/errors');
const { ok, successMessage } = require('../../src/utils/responses');
@@ -254,7 +255,7 @@ module.exports = function({ asyncHandler, errorResponse, log, session, dataDir }
// server log on every unconfigured-install invite.
log.warn && log.warn('auth-invite-send',
'invite send skipped: SMTP not configured (operator opted in)',
{ inviteId: issued.id, email: issued.email });
{ inviteId: issued.id, email: AuthProvider.maskEmail(issued.email) || '[unmaskable-email]' });
deliveredVia = 'failed';
}
} catch (sendErr) {
@@ -369,7 +370,7 @@ module.exports = function({ asyncHandler, errorResponse, log, session, dataDir }
log.info && log.info('auth', 'invite accepted, user created', {
userId: userResult.user.id,
email: userResult.user.email,
email: AuthProvider.maskEmail(userResult.user.email) || '[unmaskable-email]',
role: userResult.user.role,
inviteId: invite.id,
});
+86 -5
View File
@@ -40,7 +40,15 @@ module.exports = function({ notification, asyncHandler, ok }) {
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 || ''
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,
@@ -54,6 +62,39 @@ module.exports = function({ notification, asyncHandler, ok }) {
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) {
@@ -96,6 +137,12 @@ module.exports = function({ notification, asyncHandler, ok }) {
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
@@ -124,16 +171,50 @@ module.exports = function({ notification, asyncHandler, ok }) {
};
}
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 = {
...notificationConfig.providers.email,
...providers.email
...stored,
...incoming
};
}
}
// Update events
// 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) {
notificationConfig.events = { ...notificationConfig.events, ...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
@@ -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
+100 -12
View File
@@ -32,6 +32,10 @@ const CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_INTERVAL || '30000', 10
const MAX_CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_MAX_INTERVAL || '300000', 10); // 5 minutes max backoff
const MAX_ENTRIES_PER_SERVICE = parseInt(process.env.HEALTH_MAX_ENTRIES || '500', 10); // Cap to prevent disk explosion
const HISTORY_RETENTION_DAYS = parseInt(process.env.HEALTH_HISTORY_RETENTION || '30', 10);
// DC-088: how long a removal tombstone outlives the removal itself. Only needs
// to cover the max in-flight probe lifetime (timeout + scheduling headroom);
// swept by cleanupHistory so removed services cannot accumulate map entries.
const REMOVED_GENERATION_TTL_MS = parseInt(process.env.HEALTH_REMOVED_GEN_TTL || '600000', 10);
// DC-086: hysteresis thresholds for badge display.
// The raw probe result can flap on a single transient blip (Caddy reload,
@@ -72,6 +76,14 @@ class HealthChecker extends EventEmitter {
this.serviceTimers = new Map(); // serviceId -> timer for per-service backoff
// Invalidate probe completions that race with removal/reconfiguration.
this.serviceGenerations = new Map(); // serviceId -> configuration generation
// DC-088: monotonically increasing sequence so generation numbers can never
// repeat across remove -> re-add cycles (prevents ABA on the stale check).
this.generationSeq = 0;
// DC-088: serviceId -> { generation, removedAt } tombstones. A live entry in
// serviceGenerations means the service is (re)configured; a tombstone with a
// HIGHER generation than the captured one marks the capture as stale. Entry
// is deleted when the service is removed, so the live map cannot leak.
this.removedGenerations = new Map();
}
/**
@@ -139,6 +151,21 @@ class HealthChecker extends EventEmitter {
this.cleanupHistory();
}
/**
* DC-088: true when a probe's captured generation no longer matches the
* service's current configuration state. A live serviceGenerations entry
* must match exactly. With no live entry the service was never configured
* in this process (disk-loaded / direct callers) — stale only if a removal
* tombstone with a HIGHER generation exists.
*/
_isStaleCapture(serviceId, generation) {
if (this.serviceGenerations.has(serviceId)) {
return this.serviceGenerations.get(serviceId) !== generation;
}
const tomb = this.removedGenerations.get(serviceId);
return Boolean(tomb && tomb.generation > generation);
}
/**
* Check a single service
*/
@@ -160,7 +187,7 @@ class HealthChecker extends EventEmitter {
details: result.details
};
if ((this.serviceGenerations.get(serviceId) || 0) !== generation) {
if (this._isStaleCapture(serviceId, generation)) {
return status;
}
@@ -172,16 +199,14 @@ class HealthChecker extends EventEmitter {
}
const previousStatus = this.currentStatus.get(serviceId);
const previousDisplayed = this.displayedStatus.get(serviceId);
this.recordStatus(serviceId, status);
this.checkForIncidents(serviceId, status, config, previousStatus);
this.checkForIncidents(serviceId, status, config, previousStatus, previousDisplayed);
return status;
} catch (error) {
const responseTime = Date.now() - startTime;
// Increment failure count for backoff
this.consecutiveFailures.set(serviceId, (this.consecutiveFailures.get(serviceId) || 0) + 1);
const status = {
serviceId,
timestamp: new Date().toISOString(),
@@ -190,13 +215,18 @@ class HealthChecker extends EventEmitter {
error: error.message
};
if ((this.serviceGenerations.get(serviceId) || 0) !== generation) {
if (this._isStaleCapture(serviceId, generation)) {
return status;
}
// Increment failure count for backoff — only after the result is known
// to be non-stale, so a removed service cannot re-create map entries.
this.consecutiveFailures.set(serviceId, (this.consecutiveFailures.get(serviceId) || 0) + 1);
const previousStatus = this.currentStatus.get(serviceId);
const previousDisplayed = this.displayedStatus.get(serviceId);
this.recordStatus(serviceId, status);
this.checkForIncidents(serviceId, status, config, previousStatus);
this.checkForIncidents(serviceId, status, config, previousStatus, previousDisplayed);
return status;
}
@@ -425,10 +455,27 @@ class HealthChecker extends EventEmitter {
/**
* Check for incidents (downtime, slow response, etc.)
*/
checkForIncidents(serviceId, status, config, previous = this.currentStatus.get(serviceId)) {
checkForIncidents(serviceId, status, config, previous = this.currentStatus.get(serviceId), previousDisplayed = null) {
// Check for status change (up -> down or down -> up)
if (previous && previous.status !== status.status) {
// DC-090: outage incidents follow the DISPLAYED (post-hysteresis) status —
// the same signal that flips the dashboard badge. A single raw "down"
// blip that hysteresis suppresses must not open a critical outage
// incident (and a suppressed blip must not resolve a real one). When the
// caller supplies the pre-probe displayed state (checkService always
// does), transitions are evaluated displayed-vs-displayed using the
// post-recordStatus state in this.displayedStatus. Direct callers with
// no hysteresis state (previousDisplayed === null) keep the legacy
// raw-probe transition semantics.
if (previousDisplayed) {
const displayed = this.displayedStatus.get(serviceId);
if (displayed && displayed.status !== previousDisplayed.status) {
if (displayed.status === 'down') {
this.createIncident(serviceId, 'outage', 'Service is down', displayed);
} else if (displayed.status === 'up') {
this.resolveIncident(serviceId, 'outage', displayed);
}
}
} else if (previous && previous.status !== status.status) {
if (status.status === 'down') {
this.createIncident(serviceId, 'outage', 'Service is down', status);
} else if (status.status === 'up') {
@@ -660,7 +707,13 @@ class HealthChecker extends EventEmitter {
this.config.services = {};
}
this.serviceGenerations.set(serviceId, (this.serviceGenerations.get(serviceId) || 0) + 1);
// DC-088: monotonic instance-wide sequence — a re-added service can never
// recycle a previous generation number, and any older in-flight capture is
// invalidated by definition.
this.generationSeq += 1;
this.serviceGenerations.set(serviceId, this.generationSeq);
// Re-configuration supersedes any prior removal tombstone.
this.removedGenerations.delete(serviceId);
this.config.services[serviceId] = {
enabled: config.enabled !== false,
name: config.name || serviceId,
@@ -683,12 +736,35 @@ class HealthChecker extends EventEmitter {
* Remove service configuration
*/
removeService(serviceId) {
this.serviceGenerations.set(serviceId, (this.serviceGenerations.get(serviceId) || 0) + 1);
// DC-088: tombstone the captured generation instead of leaking an entry.
// The live map entry is deleted; an in-flight probe captured BEFORE this
// point sees no live entry but a higher tombstone generation, so it is
// discarded. configureService clears the tombstone on re-add.
this.generationSeq += 1;
this.serviceGenerations.delete(serviceId);
this.removedGenerations.set(serviceId, {
generation: this.generationSeq,
removedAt: Date.now()
});
if (this.config.services) {
delete this.config.services[serviceId];
this.saveConfig();
}
// DC-088: open incidents for a removed service must not linger forever.
// Close them through the same resolve path a recovery would, annotated so
// history shows why (dashboard renders resolved incidents green + duration).
for (const incident of this.incidents) {
if (incident.serviceId === serviceId && incident.status === 'open') {
incident.status = 'resolved';
incident.resolvedAt = new Date().toISOString();
incident.duration = new Date(incident.resolvedAt) - new Date(incident.createdAt);
incident.resolvedBy = 'service-removed';
this.emit('incident-resolved', incident);
this.emit('log', 'info', `Incident closed by service removal: ${incident.id}`);
}
}
this.currentStatus.delete(serviceId);
this.displayedStatus.delete(serviceId);
this.consecutiveSinceChange.delete(serviceId);
@@ -714,6 +790,18 @@ class HealthChecker extends EventEmitter {
this.history[serviceId] = this.history[serviceId].slice(-MAX_ENTRIES_PER_SERVICE);
}
}
// DC-088: sweep expired removal tombstones. After the TTL no probe that
// captured a pre-removal generation can still be in flight (timeout is
// bounded by performHealthCheck), so the tombstone has done its job.
if (this.removedGenerations.size > 0) {
const now = Date.now();
for (const [serviceId, tomb] of this.removedGenerations) {
if (now - tomb.removedAt > REMOVED_GENERATION_TTL_MS) {
this.removedGenerations.delete(serviceId);
}
}
}
}
/**
+5 -1
View File
@@ -14,7 +14,11 @@ const KNOWN_KEYS = [
'configurationType', 'defaults', 'customLogo', 'customFavicon',
'dashboardTitle', 'tailscale', 'license', 'skipped',
'routingMode', 'domain', 'email', 'defaultIP', 'pylon',
'customLogoDark', 'customLogoLight', 'language'
'customLogoDark', 'customLogoLight', 'language',
// license-manager.js persists the last activation to config.licenseBackup
// (restore-on-restart path); src/config/migrations.js stamps _version.
// Both are first-party writes — see DC-091.
'licenseBackup', '_version'
];
/**
+60 -60
View File
File diff suppressed because one or more lines are too long
+30 -14
View File
@@ -240,9 +240,23 @@
document.getElementById('ntfy-server').value = config.providers.ntfy.serverUrl;
}
// email fields
// email fields — DC-092: prefill the FULL form so a save doesn't
// silently wipe fields the GET response previously omitted. Password
// is never returned; when one is stored the field shows a keep-hint
// and an empty submit preserves the stored credential server-side.
if (config.providers?.email?.host) document.getElementById('email-host').value = config.providers.email.host;
if (config.providers?.email?.from) document.getElementById('email-from').value = config.providers.email.from;
if (config.providers?.email?.to) document.getElementById('email-to').value = config.providers.email.to;
if (config.providers?.email?.port) document.getElementById('email-port').value = config.providers.email.port;
if (config.providers?.email?.secure !== undefined) document.getElementById('email-secure').checked = config.providers.email.secure === true;
if (config.providers?.email?.username) document.getElementById('email-user').value = config.providers.email.username;
const emailPassEl = document.getElementById('email-pass');
if (config.providers?.email?.hasPassword) {
emailPassEl.value = '';
emailPassEl.placeholder = 'saved — leave blank to keep';
} else {
emailPassEl.placeholder = 'app password';
}
// Health check
document.getElementById('health-check-enabled').checked = config.healthCheck?.enabled || false;
@@ -254,12 +268,14 @@
`Last check: ${new Date(config.healthCheck.lastCheck).toLocaleString()}`;
}
// Events
document.getElementById('event-container-down').checked = config.events?.containerDown !== false;
document.getElementById('event-container-up').checked = config.events?.containerUp !== false;
document.getElementById('event-deploy-success').checked = config.events?.deploymentSuccess !== false;
document.getElementById('event-deploy-failed').checked = config.events?.deploymentFailed !== false;
document.getElementById('event-resource-alert').checked = config.events?.resourceAlert !== false;
// Events — canonical kebab-case keys, matching the backend store
// (DC-092: previously read camelCase keys that never existed, so
// every toggle re-rendered as 'checked' regardless of stored state).
document.getElementById('event-container-down').checked = config.events?.['container-down'] !== false;
document.getElementById('event-container-up').checked = config.events?.['container-up'] === true;
document.getElementById('event-deploy-success').checked = config.events?.['deploy-success'] !== false;
document.getElementById('event-deploy-failed').checked = config.events?.['deploy-failed'] !== false;
document.getElementById('event-resource-alert').checked = config.events?.['alert'] !== false;
}
} catch (error) {
errorHandler.logError('[Notifications] Load Config', error, { function: 'loadConfig' });
@@ -325,18 +341,18 @@
host: document.getElementById('email-host').value.trim(),
port: parseInt(document.getElementById('email-port').value) || 587,
secure: document.getElementById('email-secure').checked,
user: document.getElementById('email-user').value.trim(),
pass: document.getElementById('email-pass').value.trim(),
username: document.getElementById('email-user').value.trim(),
password: document.getElementById('email-pass').value.trim(),
from: document.getElementById('email-from').value.trim(),
to: document.getElementById('email-to').value.trim()
}
},
events: {
containerDown: document.getElementById('event-container-down').checked,
containerUp: document.getElementById('event-container-up').checked,
deploymentSuccess: document.getElementById('event-deploy-success').checked,
deploymentFailed: document.getElementById('event-deploy-failed').checked,
resourceAlert: document.getElementById('event-resource-alert').checked
'container-down': document.getElementById('event-container-down').checked,
'container-up': document.getElementById('event-container-up').checked,
'deploy-success': document.getElementById('event-deploy-success').checked,
'deploy-failed': document.getElementById('event-deploy-failed').checked,
'alert': document.getElementById('event-resource-alert').checked
},
healthCheck: {
enabled: document.getElementById('health-check-enabled').checked,
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'dashcaddy-shell-497e1f671c';
const CACHE = 'dashcaddy-shell-3354f5fd96';
const PRECACHE = [
'/',
'/index.html',