Files
dashcaddy/dashcaddy-api/routes/caddy-upstreams.js
T
DashCaddy Polish Loop c01a011d47
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
[glm-grade=A] fix(caddy-upstreams): swap errorResponse arg order to statusCode-first; add type validator (DC-062)
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)
2026-08-18 09:03:58 -07:00

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;
};