Routes/caddy-upstreams.js had 4 callsites with argument-order swapped: errorResponse(res, 'message', 503) instead of errorResponse(res, 503, 'message'). The canonical signature from src/utils/responses.js:66 takes statusCode FIRST; the swapped call passed a STRING where Express expected a status code. res.status('Caddy upstream watcher not initialized') throws RangeError [ERR_HTTP_INVALID_STATUS_CODE], Express's error middleware catches it, and the response is 500 with an HTML stack trace instead of the intended 503 JSON. Four `!caddyUpstreamWatcher` defensive guards had this exact pattern; all fixed.
Defense-in-depth (responses.js): errorResponse() now validates that statusCode is an integer in 100..599 and that message is a string BEFORE calling res.status(). Future arg-order mistakes fail fast with a clear TypeError naming the wrong arg and the message — instead of writing a 500 HTML panic to the wire. Legacy error(res, message, statusCode) helper (used by ~7 files that import as 'error: errorResponse' alias) is intentionally untouched.
Tests (__tests__/utils-responses-dc-062.test.js, NEW, 21 tests pass):
- correct (res, 503, msg) order: 503 JSON
- swapped (res, msg, statusCode) order: TypeError (was: silent 500 HTML panic)
- 10 invalid-statusCode cases: NaN, Infinity, '503', null, undefined, underflow, overflow, float, object, array — all rejected
- non-string message rejected
- DC-086 extras.code propagation preserved
- legacy error() helper regression: still works
- pre-fix Express server proves the bug class (500 HTML when statusCode is a string)
- all 4 caddy-upstreams routes with null watcher now return 503 JSON
- static source scan: 0 swapped patterns, 4 canonical (statusCode, 'message') occurrences
Full suite: 92 suites / 2039 tests / all green pre and post fix.
[glm-grade=A] from deleg_45e44614 (3 tool calls, 82s, MiniMax-M3 stand-in per Sami's 2026-08-17 authorization)
116 lines
5.1 KiB
JavaScript
116 lines
5.1 KiB
JavaScript
/**
|
|
* Caddy upstreams routes
|
|
*
|
|
* Exposes:
|
|
* GET /api/v1/caddy/upstreams — full snapshot
|
|
* GET /api/v1/caddy/upstreams/incidents — open dead-upstream incidents (via healthChecker)
|
|
* POST /api/v1/caddy/upstreams/:host/mute — body { muted: true|false } (also via query ?muted=true)
|
|
*
|
|
* Auth: same as the rest of /api/v1 — handled by the global middleware
|
|
* (the router is mounted under the auth-gated apiRouter in app.js).
|
|
*
|
|
* @module routes/caddy-upstreams
|
|
*/
|
|
|
|
const express = require('express');
|
|
const { success, errorResponse } = require('../src/utils/responses');
|
|
const { ValidationError } = require('../src/utilities/errors');
|
|
|
|
module.exports = function({ asyncHandler, caddyUpstreamWatcher, healthChecker }) {
|
|
const router = express.Router();
|
|
|
|
router.get('/caddy/upstreams', asyncHandler(async (req, res) => {
|
|
if (!caddyUpstreamWatcher) {
|
|
// DC-062: errorResponse(res, statusCode, message) — statusCode-first per
|
|
// src/utils/responses.js:66. The prior (res, message, statusCode) call
|
|
// order passed a STRING as the status code, which made
|
|
// res.status('Caddy upstream watcher not initialized') throw
|
|
// RangeError [ERR_HTTP_INVALID_STATUS_CODE] (Express turning it into a
|
|
// 500 with an HTML stack trace). All four `!caddyUpstreamWatcher`
|
|
// guards had the same latent bug — fixed to canonical order.
|
|
return errorResponse(res, 503, 'Caddy upstream watcher not initialized');
|
|
}
|
|
success(res, caddyUpstreamWatcher.snapshot());
|
|
}, 'caddy-upstreams-list'));
|
|
|
|
router.get('/caddy/upstreams/incidents', asyncHandler(async (req, res) => {
|
|
if (!healthChecker) {
|
|
return success(res, { incidents: [] });
|
|
}
|
|
// Filter the in-memory incidents array to caddy-upstream-dead entries.
|
|
const all = Array.isArray(healthChecker.incidents) ? healthChecker.incidents : [];
|
|
const open = all
|
|
.filter((i) => i && i.type === 'caddy-upstream-dead' && i.status === 'open')
|
|
.map((i) => ({
|
|
id: i.id,
|
|
serviceId: i.serviceId,
|
|
type: i.type,
|
|
message: i.message,
|
|
severity: i.severity,
|
|
createdAt: i.createdAt,
|
|
lastOccurrence: i.lastOccurrence,
|
|
occurrences: i.occurrences,
|
|
details: i.details
|
|
}));
|
|
success(res, { incidents: open });
|
|
}, 'caddy-upstreams-incidents'));
|
|
|
|
// POST /caddy/upstreams/mute body { host, muted }
|
|
// POST /caddy/upstreams/:host/mute body { muted: true } OR query ?muted=true
|
|
// Both shapes supported because the dashboard code is small and either is
|
|
// ergonomic depending on caller.
|
|
const handleMute = asyncHandler(async (req, res) => {
|
|
if (!caddyUpstreamWatcher) {
|
|
return errorResponse(res, 503, 'Caddy upstream watcher not initialized');
|
|
}
|
|
const host = req.params.host || req.body?.host;
|
|
if (!host || typeof host !== 'string' || !/^[a-z0-9._:-]+$/i.test(host)) {
|
|
throw new ValidationError('host must be a valid host[:port] string');
|
|
}
|
|
// Accept muted as boolean body field OR ?muted=true|false query OR
|
|
// a { muted: true|false } JSON body. Default to toggling on bare POST
|
|
// without a muted value (this is the "mute it" path).
|
|
let muted;
|
|
if (typeof req.body?.muted === 'boolean') muted = req.body.muted;
|
|
else if (typeof req.query.muted === 'string') muted = req.query.muted === 'true';
|
|
else muted = true; // POST with no body = mute
|
|
|
|
const result = caddyUpstreamWatcher.setMuted(host, muted);
|
|
success(res, result);
|
|
}, 'caddy-upstreams-mute');
|
|
|
|
// Bare /mute with JSON body {host, muted}. Default mutes when muted is
|
|
// absent or unparseable; require muted === false explicitly to unmute.
|
|
router.post('/caddy/upstreams/mute', asyncHandler(async (req, res) => {
|
|
if (!caddyUpstreamWatcher) {
|
|
return errorResponse(res, 503, 'Caddy upstream watcher not initialized');
|
|
}
|
|
const { host, muted } = req.body || {};
|
|
if (!host || typeof host !== 'string' || !/^[a-z0-9._:-]+$/i.test(host)) {
|
|
throw new ValidationError('host must be a valid host[:port] string');
|
|
}
|
|
// Explicit boolean coercion — string 'false' should NOT mute.
|
|
const wantMuted = muted === undefined ? true : muted === true;
|
|
if (caddyUpstreamWatcher.upstreams && !caddyUpstreamWatcher.upstreams.has(host)) {
|
|
throw new ValidationError(`host ${host} is not a known upstream (run scan first)`);
|
|
}
|
|
const result = caddyUpstreamWatcher.setMuted(host, wantMuted);
|
|
success(res, result);
|
|
}, 'caddy-upstreams-mute-bare'));
|
|
|
|
// /:host/mute and /:host/unmute for path-style toggles
|
|
router.post('/caddy/upstreams/:host/mute', handleMute);
|
|
router.post('/caddy/upstreams/:host/unmute', asyncHandler(async (req, res) => {
|
|
if (!caddyUpstreamWatcher) {
|
|
return errorResponse(res, 503, 'Caddy upstream watcher not initialized');
|
|
}
|
|
const host = req.params.host;
|
|
if (!host || !/^[a-z0-9._:-]+$/i.test(host)) {
|
|
throw new ValidationError('host must be a valid host[:port] string');
|
|
}
|
|
const result = caddyUpstreamWatcher.setMuted(host, false);
|
|
success(res, result);
|
|
}, 'caddy-upstreams-unmute'));
|
|
|
|
return router;
|
|
}; |