Commit Graph
319 Commits
Author SHA1 Message Date
Krystie 1baef432c4 DC-044: also handle /api/v1/auth/* drift variant in back-compat shim
The user's browser cached an older version of the auto-login page that
called /dashcaddy-api/api/v1/auth/totp/check-session (with both v1 and
auth prefixes) instead of the current /dashcaddy-api/api/auth/totp/check-session
(legacy, no v1). The shim only handled the legacy path, so the stale
JS 404'd and the page hung at 'Signing in to Plex...' even after the
fix was deployed.

Add /api/v1/auth/{gate,app-token,totp/check-session} to the shim so
stale browser caches keep working. Also add /api/v1/auth/gate and
/api/v1/auth/app-token for the same drift reason.
2026-07-08 21:42:03 -07:00
Krystie cf8909740f DC-044: fix legacy /api/auth/totp/check-session shim path (drop /auth)
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
The shim added in the previous commit rewrote /api/auth/totp/check-session
to /api/v1/auth/totp/check-session, but the canonical route is mounted at
/totp/check-session (no /auth prefix). The 404 returned to the auto-login
JS path was Route GET /v1/auth/totp/check-session — Express's /api/v1
mount stripped the /api/v1 prefix, leaving /auth/totp/check-session, which
doesn't match /totp/check-session.

Drop both /api and /auth (9 chars) so the legacy path maps to the
canonical /api/v1/totp/check-session.

Verified after deploy:
  GET /api/auth/totp/check-session  -> {"authenticated":true}
  GET /api/v1/totp/check-session    -> {"authenticated":true}
2026-07-08 21:33:07 -07:00
Krystie a7b0714643 DC-044: fix plex.sami auto-login JS 404 (add check-session to legacy shim)
The Plex/Jellyfin/Emby/chat auto-login page JS (sso-gate.js
buildLoginPage) calls /api/auth/totp/check-session — the pre-1.5.0
legacy prefix. The back-compat shim in app.js only handled
/api/auth/gate/ and /api/auth/app-token/, so check-session 404'd and
the page hung at "Signing in to Plex..." forever (user reported
2026-07-09, confirmed: request returns "Route GET /v1/auth/totp/
check-session not found").

Add /api/auth/totp/check-session to the legacy path rewrite so the JS
gets the canonical /api/v1/totp/check-session endpoint.

Verified: plex.sami/dashcaddy-login now returns the auto-login page
and the JS check-session fetch resolves to {"authenticated":true} for
active TOTP sessions.
2026-07-08 21:33:06 -07:00
Krystie d539ee3b08 DC-044: fix /health/ready caddy probe false negative
The caddy.ok check in /health/ready probed /config/ (51KB) and timed out
at 3s with "This operation was aborted" while Caddy admin was actually
healthy. Two underlying issues:

1. Native undici fetch() rejects connections to :2019 (Caddy admin). Use
   fetchT() which falls back to raw http.request for the admin port.
2. /config/ is heavy and head-of-line blocks when /load is in flight.
   Switch to /config/apps/http/servers/srv0/listen (9 bytes) and bump
   timeout to 10s.

Verified on DNS2 2026-07-09: direct Caddy admin curl 200 in 3ms,
/health/ready was aborting at 3s. After fix: /health/ready caddy.ok
true in <100ms.

Caddyfile change (/etc/caddy/Caddyfile) added /dashcaddy-login to the
@needsAuth not path exclude so direct hits to the auto-login landing
page render the page instead of getting gate-redirected to a blank
302 — applied and reloaded via POST /load earlier this session.
2026-07-08 21:33:06 -07:00
Krystie 6fb4f9b169 DC-043: tailscale coordination API client + admin/settings routes
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
Companion to DC-042 (tailscale-manager). Adds the write-side of Tailscale
integration — REST client for api.tailscale.com that lets DashCaddy
manage its own tailnet (devices, pre-auth keys, users, ACL).

src/managers/tailscale-coord.js — new module:
  * Ping, list/get/delete devices, create/list/delete pre-auth keys,
    list users, get/update ACL
  * 5min read cache, 60s device-list cache, 1hr ping cache
  * Cache invalidation on writes
  * Graceful {configured:false} when no token
  * TailscaleCoordError class with code mapping (unauthorized, not_found,
    rate_limited, server_error)
  * fetchImpl injection point for tests; native https in production
  * 45 unit tests covering all paths

src/context/index.js — new tailscaleCoord namespace:
  * getClient() lazy-builds a fresh client each call (token re-read from
    credentialManager so settings changes take effect without restart)
  * loadMetadata/saveMetadata for tailscale-config.json
  * setApiToken/hasApiToken wrappers around credentialManager

routes/tailscale-admin.js — new routes:
  * GET    /api/v1/tailscale/settings         — config status, never the token
  * PUT    /api/v1/tailscale/settings         — validate + store encrypted
  * DELETE /api/v1/tailscale/settings         — wipe token + metadata
  * POST   /api/v1/tailscale/settings/test    — ping without saving
  * GET    /api/v1/tailscale/admin/devices    — full device list
  * DELETE /api/v1/tailscale/admin/devices/:id — revoke device
  * GET    /api/v1/tailscale/admin/users      — tailnet users
  * GET    /api/v1/tailscale/admin/keys       — pre-auth key metadata
  * POST   /api/v1/tailscale/admin/keys       — create pre-auth key (returns secret ONCE)
  * DELETE /api/v1/tailscale/admin/keys/:id   — revoke pre-auth key
  * 29 route integration tests with supertest

src/app.js — wired the new router into the /api/v1/tailscale mount.

BACKLOG.md — DC-042 marked done, DC-043 added with full design notes.
            Note: deliberately no token auto-rotation — Tailscale API keys
            don't auto-renew, and silently re-issuing admin credentials
            would erode the audit-trail checkpoint that token expiry
            provides.

Tests: 1167 -> 1212 (+45), all green. Lint clean.
2026-07-06 21:28:03 -07:00
Krystie d04238621f DC-042: implement real Tailscale manager — replace null stub
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
The previous getTailscaleStatus() in src/app.js was a hard-coded
`return null` stub with a TODO saying it would be populated by context.
The context had a tailscale.* namespace declared with null function
stubs (routes/context.js:71), but nothing ever set them to real
functions. routes/tailscale.js has been calling ctx.tailscale.getStatus()
/ getLocalIP() / isTailscaleIP() and getting undefined back, silently
returning empty device lists. The tailscaleAuthMiddleware's allowedTailnet
check (DC-121, device-not-in-tailnet 403) was dead code for the same reason.

This commit replaces the stub with a real implementation:

- New src/managers/tailscale-manager.js shells out to the host's
  `tailscale status --json` (cached 5 minutes), parses the result, and
  exposes getStatus / getLocalIP / getSummary / getDevices / isTailscaleIP /
  invalidateCache / getAccessToken (stub) / startSyncTimer / stopSyncTimer
  / syncAPI (stub). All failure modes (CLI missing, tailscaled down,
  malformed JSON, EACCES) are handled gracefully — return null with no
  cache poisoning.
- src/context/index.js now wires the manager into ctx.tailscale.* so
  routes/tailscale.js and middleware.js's allowedTailnet gate get the
  real functions.
- src/app.js:189 getTailscaleStatus() now delegates to the manager
  instead of returning null.
- The duplicate isTailscaleIP() in src/app.js:179 (no malformed-input
  guards) is removed in favor of the canonical version in
  src/utilities/network-detector.js (DC-031) which the manager also uses.
- start.sh now bind-mounts /usr/bin/tailscale (statically linked Go binary
  — works under Alpine libc) and /var/run/tailscale/ into the container,
  read-only. Lets the container invoke the CLI without needing its own
  tailscale install.
- 41 new unit tests in __tests__/tailscale-manager.test.js cover: CLI
  success/missing/daemon-down/malformed-JSON paths, 5-min cache hit/miss,
  1-hour installed-cache hit/miss, getLocalIP IPv4/IPv6/missing-choices,
  getSummary shape, getDevices shape with full + minimal peer fields,
  startSyncTimer/stopSyncTimer interval + idempotency, TAILSCALE_BIN env
  override.

Total: 1138 tests pass (was 1097, +41 new), 0 new ESLint warnings.

What this unlocks:
- /api/v1/tailscale/status → real installed/connected/hostname/ip/
  peerCount/onlinePeerCount summary instead of empty
- /api/v1/tailscale/devices → real device list (was returning [])
- /api/v1/tailscale/check-connection → works (uses real isTailscaleIP)
- tailscaleAuthMiddleware allowedTailnet check (DC-121) is no longer
  dead code — a request from a Tailscale IP not in the allowed tailnet
  now actually gets 403 instead of being silently allowed.
2026-07-06 18:55:16 -07:00
Krystie ca705fe59f DC-025: sync DC-025 hardening (channel gate, locked-file deploy_tree, deploy_mode, post-deploy patches, dns-providers handling) into canonical dashcaddy-api/scripts/dashcaddy-update.sh
The host-side /opt/dashcaddy/scripts/dashcaddy-update.sh was hardened in
DC-025 (commit bfa4ba5, 2026-07-05), but the canonical script at
dashcaddy-api/scripts/dashcaddy-update.sh was never updated. This created
a drift hazard: anyone running release.sh and rebuilding the install
tarball would propagate the pre-hardening version, undoing DC-025 on
fresh hosts.

This commit syncs the hardening from the host-side script to the canonical,
so the next release builds and ships the hardened version. Specifically
adds:
- channel_allowed() gate (refuse prereleases unless ALLOW_PRERELEASE=true)
- deploy_mode() dispatch (compose / start.sh / bare docker run)
- build_image() helper
- deploy_tree() with chattr +i preservation and empty-staging-dir guard
- Post-deploy patches invocation (dashcaddy-post-deploy-patches.sh)
- dns-providers directory backup/restore

Verified: bash -n passes on both scripts; canonical and host-side are now
byte-identical (md5 a72e1dc37fb3487edc00e81ea37ac60b).

Discovered while investigating a WIP on DNS2 that had silently reverted
these features. That WIP was discarded (the BACKLOG entry it claimed to
satisfy described an implementation that didn't exist in the diff).
2026-07-06 15:26:10 -07:00
Krystie 369827c43f DC-031: fix /api/v1/network/ips ReferenceError + add regression tests
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
- Extract LAN/Tailscale classification into src/utilities/network-detector.js
  (detectInterfaceIps, isTailscaleIP, isPrivateLanIP). The route handler in
  src/app.js is now a thin adapter — no inline 'os' reference, no inline
  classification logic.
- Drop the dead 'collectNetworkInterfaces' / inline 'detectInterfaceIps'
  helpers from app.js (the original ReferenceError shape).
- Add __tests__/network-ips-route.test.js (16 tests):
  - Detector unit tests for Tailscale CGNAT (100.64/10) and RFC 1918 LAN
    ranges with malformed-input guards.
  - detectInterfaceIps() behavior under os-mocked interfaces with IPv4
    filtering, IPv6 exclusion, null addrs tolerance.
  - Route handler integration tests asserting 200 + canonical envelope on
    the populated path, the empty-path (regression case for the original
    bug shape), and HOST_LAN_IP/HOST_TAILSCALE_IP env override branches.
  - Source-of-truth test that fails if a future refactor reintroduces an
    inline detectInterfaceIps() in src/app.js or references 'os' without
    a prior require('os') line.
- Fix latent ESLint Error in backup-manager.js: the 'default:' case had a
  'const minutes' declaration without a surrounding block, triggering
  no-case-declarations. Added the block braces.

Pre-fix baseline: no test exercised this route, so the 1071-test suite
passed despite the 500. Post-fix: 1087/1087 tests pass (+16 new), zero new
ESLint warnings (10 pre-existing warnings in backup-manager.js unrelated
to this commit).
2026-07-06 15:06:28 -07:00
Krystie 71fd7cd58f DC-035: add regression test for SelfUpdater.getLocalVersion() (DC-033 class)
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
Adds 6 tests that catch the exact bug DC-033 fixed. Verified to actually
fail (4/6) against the pre-fix code (git show 20d280f^:self-updater.js),
proving it's a real regression test and not a placebo. Full suite: 40/40
suites, 1081/1081 tests.
2026-07-05 22:49:06 -07:00
Krystie 7ec428f34f DC-036: delete dead dashcaddy-api/self-updater.js (0 callers); sync VERSION to 42376e2
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-07-05 22:43:55 -07:00
Krystie 42376e2186 chore(release): bump to 1.14.9 (DC-033 fix baked in for auto-update)
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-07-05 22:02:46 -07:00
Krystie 77536f4486 DC-033: bump VERSION to 20d280f (DC-033 commit SHA)
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-07-05 21:50:46 -07:00
Krystie 20d280f1dd DC-033: fix getLocalVersion __dirname resolution
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
The SelfUpdater's getLocalVersion() used __dirname to find package.json
and VERSION, but server.js loads the module via './src/docker/self-updater'
so __dirname resolves to /app/src/docker inside the container — which
has no package.json. Result: /api/v1/system/version silently returned
{version: '0.0.0', commit: null} and checkForUpdate() always thought we
were outdated.

Walk a candidate list of paths (api root first, __dirname second) so the
function works regardless of where the module is required from. Log to
stderr on total failure instead of swallowing silently.

Verified on DNS2: /api/v1/system/version now returns
{"name":"DashCaddy","version":"1.14.8","commit":"fef7e07"}
(v1.14.8 with the security fixes DC-020..032).
2026-07-05 21:49:39 -07:00
Krystie ba23cdff02 DC-032: fix health checker authLimiter feedback loop + ca.sami DNS
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
Three coordinated changes to stop every gated *.sami service flipping
red after ~20 probes:

1. health-checker.js _doRequest() now sends X-DashCaddy-HealthCheck: 1
   on every outgoing probe. Caddy uses this header (combined with a
   trusted source IP via the new @healthcheckProbe matcher in the
   dashcaddy_auth snippet) to bypass forward_auth for local container
   probes. Without the bypass, forward_auth 401's every probe, and the
   authLimiter (20 req / 15 min, DC-027) caps us out within minutes.

2. evaluateHealth() default expectedStatusCodes now includes 401, 403,
   and 429. Defense in depth — if a future Caddy reload drops the
   bypass, 429 from the rate-limited gate no longer marks the service
   as down (it just means the gate answered, which proves the service
   is reachable through Caddy).

3. (start.sh — already shipped on the running container, will land
   with the next release build) ca.sami now maps to 100.121.150.22
   (DNS2) instead of 127.0.0.1, which is the container's own loopback
   where nothing serves :443. The CA web UI lives on DNS2's Caddy.

Tests:
- evaluateHealth: 401, 403, 429 accepted by default
- _doRequest: X-DashCaddy-HealthCheck: 1 always present
- _doRequest: user-supplied headers preserved alongside marker

Bump 1.14.7 → 1.14.8.
2026-07-05 12:58:13 -07:00
Krystie a92eeceae5 DC-029: skip authLimiter for already-authenticated requests
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
The DC-027 rate limiter on /api/v1/auth/* shipped with skip: () => isTest,
which counted every request — including those from a logged-in TOTP session.
Caddy's forward_auth fires /auth/gate/* on every page-load asset (HTML, JS,
CSS, XHR), so a normal browser session exhausted the 20-req/15-min budget
within ~3 page loads and started getting 429 'Too many auth requests' even
with a valid session cookie.

Fix: extend skip to also return true when req.auth.type is 'session',
'jwt', or 'apikey' (set by jwtApiKeyAuthMiddleware, which runs upstream
of the limiter). The unauthenticated path is still rate-limited — DC-027's
credential-scraping defense is preserved.

Also closes the uncommitted working-tree changes for:
- DC-026: routes/auth/sso-gate.js — pre-auth check in buildLoginPage,
  redirected error fallbacks to status.sami?auth=required&return=...
- DC-022: dashcaddy-api/VERSION bumped to fef7e07
- status/index.html + status/js/tailscale-devices.js — Tailscale device card

4 new regression tests pin the fix:
- skips when req.auth.type === 'session'
- skips when req.auth.type === 'jwt'
- skips when req.auth.type === 'apikey'
- still counts UNAUTHENTICATED requests (defense preserved)

Live verified: 50/50 authenticated /auth/gate/plex calls passed (was
20/30 before fix). plex.sami/dashcaddy-login returns 200 with no redirect
loop. Plex auto-login token round-trips end-to-end.
2026-07-02 18:27:03 -07:00
Hermes f94b164190 DC-020: fix last broken require in server.js (./state-manager -> ./src/managers/state-manager)
The DC-020 require-path sweep fixed every '../src/...' -> './src/...' in
server.js, but missed one: line 73 still had .
From the production entry point (/app/server.js) this resolves to
/app/state-manager.js — a file that does NOT exist (the module lives at
src/managers/state-manager.js). Unlike the optional modules below it,
this require is bare (not wrapped in try/catch), so a MODULE_NOT_FOUND
here throws out of the top-level startup IIFE and crash-loops the
container — the exact same failure mode as the deleted license-keygen.js.

Fix: ./state-manager -> ./src/managers/state-manager (matches line 146).

Also hardens the DC-020 regression guard (app-startup-smoke.test.js):
adds a static check that EVERY relative require() in server.js resolves
to a real file on disk. server.js cannot be require()'d at test time
(its IIFE binds port 3001 + starts interval modules, leaking workers),
so the static scan is what catches this class of entry-point path bug.
This test would have failed on the original ./state-manager line.

1067/1067 tests pass (was 1066 baseline + 1 new). Zero new ESLint warnings.
2026-07-01 07:37:45 -07:00
Krystie fef7e07b49 DC-026/027/028: close 3 more auth security holes + rate limit /auth/* + audit credential exposures
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
[DC-026] routes/auth/sso-gate.js — fix sessionDuration='never' bypass
  Both /auth/gate/:serviceId and /auth/app-token/:serviceId had a session
  check gated on `sessionDuration !== 'never'`. An admin setting TOTP to
  never-expire accidentally created an authentication-free path to credential
  injection (Basic Auth, X-Api-Key, Plex/Prowlarr tokens). Patched: session
  required whenever TOTP is enabled, period. Added 8 regression tests.

[DC-027] src/utilities/middleware.js — rate limit /auth/*
  New authLimiter (20 req / 15 min) on /auth/keys, /auth/jwt, /auth/gate,
  /auth/app-token. These endpoints expose credentials and were unmetered.
  Without this, an attacker with a guessed session cookie could burn through
  every credential-touching endpoint. Added 5 tests.

[DC-028] src/security/audit-logger.js — log credential exposures
  /auth/gate and /auth/app-token were in SKIP_PATHS, silently dropping
  every credential-exposure event from the audit log. Combined with the
  GET-skip rule, NONE of these events were being recorded. Now logged
  with named actions: auth.credential-injection, auth.app-token-issue,
  auth.api-key-generate, auth.api-key-revoke, auth.jwt-mint. Added 9 tests.

[start.sh] Disable in-container self-updater
  DASHCADDY_UPDATE_ENABLED=false. Without this, the container kept writing
  trigger.json every 30 min and clobbered my in-progress host edits. The
  path unit on the host is still active for manual triggers, but the
  container won't auto-update itself — only when an admin clicks the
  update button or a new release is manually published.

[package.json] Bump to 1.14.7

Test results: 1066/1066 passing across 39 suites (added 22 new tests).
2026-07-01 04:20:57 -07:00
Krystie b7624cc507 DC-024: Bump installer version to 1.14.6 and sync VERSION to current commit
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
- dashcaddy-installer/install.sh: 1.1.0 → 1.14.6 (matches current release)
- dashcaddy-api/VERSION: 10f72afa5f51e4 (current HEAD with TOTP security fixes)

The host source tree was rebuilt from the published v1.14.6 tarball to fix a
deletion gap where /opt/dashcaddy/dashcaddy-api/{routes,src}/ were gutted by an
interrupted prior update cycle. Container was unaffected (built from image).
2026-07-01 03:50:00 -07:00
Krystie a5f51e4a0c DC-023: operational fixes — DNS, rate limiter, version sync
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
- VERSION: bump from 1.14.4 to 1.14.6 to match package.json (HEAD had stale value)
- middleware.js: apply existing totpLimiter (10/15min) to /totp/setup endpoint
  (was previously unmetered, allowing secret enumeration)
- dashcaddy-update.sh: hook post-deploy-patches.sh into the update flow
  so the container can survive transitions between broken → fixed tarballs
- start.sh: add --add-host flags for get.dashcaddy.net and get2.dashcaddy.net
  so the container can resolve the release server (was failing with ENOTFOUND)
2026-07-01 03:10:53 -07:00
Krystie 2439ed3e85 DC-022: close 3 TOTP auth security holes
1. /totp/recovery-info: was PUBLIC, leaking TOTP configuration status
   to unauthenticated attackers. Now requires valid session (401 otherwise).

2. /totp/check-session: had an unconditional bypass that returned
   authenticated:true whenever totpConfig.enabled was false. This let
   anyone reach authenticated endpoints without credentials.
   Now throws AuthenticationError instead.

3. /totp/setup: was unmetered despite generating secrets. Added 3/hour
   per-IP rate limit in addition to the existing global 10/15min limiter.

All changes verified live via https://status.sami:
- recovery-info unauth → 401 [DC-110] (was 200)
- check-session no cookie → 401 TOTP protection required (was 200)
- 4th setup attempt → 429 [DC-429]
2026-07-01 03:09:33 -07:00
Krystie 69be51b8aa chore(release): bump to 1.14.6 — patched source
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
Following DC-021 (commit 10f72af) which restored working require paths and
license-keygen.js, this commit bumps the version metadata so the next
release build publishes v1.14.6 instead of re-tagging v1.14.4.

The source is functionally v1.14.4 + fixes; the version bump tells the
updater we're ahead of upstream's broken v1.14.4.
2026-07-01 00:55:13 -07:00
Krystie 10f72af959 fix(update): proper require path fixes + license-keygen restore for v1.14.4 compatibility
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
v1.14.4 (commit d2a48b1) shipped with broken relative paths and missing
license-keygen.js module. This commit:

- server.js: 26 '../src/...' requires rewritten to './src/...' (server is
  at API root, must use ./src for files in src/)
- src/managers/license-manager.js: './license-keygen' rewritten to
  '../../license-keygen' (license-keygen.js lives at API root)
- src/docker/self-updater.js: './platform-paths' rewritten to
  '../../platform-paths' (platform-paths.js lives at API root)
- license-keygen.js: restored to root (was missing from v1.14.4 tarball)
- VERSION: bumped to d2a48b1-patched (matches upstream commit but with
  our fixes baked in)

Makes the v1.14.4 source buildable and runnable without external patches.
Companion to scripts/dashcaddy-post-deploy-patches.sh which applies these
fixes automatically during the host-side update flow.
2026-07-01 00:32:23 -07:00
Hermes 29f2c7999f DC-020: restore license-keygen.js + fix broken require paths (container crash-loop)
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
The refactor(desloppify) commit a2e6566 deleted license-keygen.js and added it
to .gitignore, believing it was stale dev-root noise. It is actually a required
production module: src/managers/license-manager.js does require('./license-keygen')
and imports verifyCode/parseCode/VALID_DURATIONS. The deletion put the production
dashcaddy-api container in a crash-restart loop (MODULE_NOT_FOUND from
/app/src/app.js -> /app/server.js). The 1036-test suite passed because no test
ever executed require() on the real app module.

Fixes:
- Restore license-keygen.js from git history (a2e6566^) to src/managers/, the
  path the post-DC-005 require resolves to. CLI main() is require.main-guarded,
  so only the library exports are used at runtime.
- Remove the license-keygen.js line from .gitignore so the restored module is
  tracked (otherwise the fix would not survive a container rebuild).
- Fix a second masked broken require: src/docker/self-updater.js required
  './platform-paths' (resolves to src/docker/, doesn't exist) -> corrected to
  '../../platform-paths' (repo root, where all 9 other callers point).
- Add .encryption-key to .gitignore (runtime AES secret that the require graph
  regenerates; was untracked + un-ignored -> latent leak on git add -A).
- Add __tests__/app-startup-smoke.test.js: executes require() on the real app
  module and asserts the full require graph resolves. This regression guard
  would have caught both broken requires.

Verified: app module now loads clean; 1038/1038 tests pass (+2 new); the smoke
test fails if either required module is missing.
2026-06-29 07:22:33 -07:00
Sami d2a48b1990 chore(release): bump to 1.14.4
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-06-29 03:22:20 -07:00
SamiandClaude Sonnet 4.6 15dee0fe18 fix(startup): correct broken .// require paths in app.js to proper subdir paths
The 489f700 fix accidentally stripped the subdirectory name from all bare
requires (e.g. managers/state-manager → .//state-manager instead of
./managers/state-manager). Fixed all 39 occurrences with correct subdir
prefixes (managers/, security/, monitoring/, docker/, utilities/, recipes/).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 03:22:11 -07:00
Sami 588af0dffe chore(release): bump to 1.14.3
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-06-29 01:21:01 -07:00
Sami 489f700cc3 fix(startup): prefix all bare src/ subdirectory requires with ./ in app.js and provider-dns.js
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
DC-005 refactored everything into src/ subdirs but left bare require()
paths in app.js (managers/, security/, monitoring/, docker/, recipes/,
utilities/, context/, dns/) which resolve fine in tests (jest mocks) but
fail in the container where NODE_PATH=/app/src is not set. Fixed ~25
requires with relative paths.
2026-06-29 01:20:39 -07:00
Sami 7855b20f63 chore(release): bump to 1.14.2
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-06-29 00:50:10 -07:00
Sami 7ef99ec42b fix(dns): correct provider-dns.js require path after dns-providers/ move to src/dns/
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-06-29 00:48:50 -07:00
Sami a37f4f571d chore(release): bump to 1.14.1
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-06-29 00:44:27 -07:00
Sami 95a8ae7a09 fix(docker): remove stale COPY dns-providers/ — moved to src/dns/dns-providers/
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-06-28 04:11:32 -07:00
Sami 80bb6098a4 chore(release): bump to 1.14.0
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-06-28 03:52:45 -07:00
SamiandClaude Sonnet 4.6 a2e6566958 refactor(desloppify): SSO login-page route, CLAUDE.md rewrite, gitignore cleanup
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
- sso-gate.js: add GET /api/v1/auth/login-page?service= route; auto-login
  HTML for chat/plex/jellyfin/emby now served from code instead of inline
  Caddyfile respond blobs. Fix merge() try-block syntax error (was missing
  closing } before catch, breaking Jellyfin/Emby localStorage merge).
- middleware.js: add /api/v1/auth/login-page to PUBLIC_ROUTES.
- CLAUDE.md: complete rewrite — was describing the old Windows-local
  C:/caddy/ layout; now accurately describes DNS2 production (paths,
  container, caddy-apply workflow, SSO architecture, common mistakes).
- .gitignore: cover runtime JSON/log/cert files that were sitting untracked
  in dev root (audit-log, backup-history, credentials, health-history, etc.),
  plus generated-certs/, pki/, assets/.
- Remove tracked dev-root noise: comprehensive-test.js, license-keygen.js,
  test-security-fixes.js (scripts that don't belong at repo root).
- Remove stale routes/openclaw.js (leftover from old monolithic structure).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 03:48:11 -07:00
Hermes 5f6c25d2e3 DC-018/DC-019: mark done, bump v1.13.5, CHANGELOG
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-06-27 07:03:41 -07:00
Hermes 1f887725fb DC-019: fix flaky backup-manager tamper test (authTag byte corruption)
The 'rejects tampered data (auth tag mismatch)' test corrupted the
encrypted blob by replacing its first base64 char with 'X'. When the
random 16-byte IV's first base64 char was already 'X' (~1/64 chance),
the replacement was a no-op and decryption succeeded — causing the test
to flake ~1.6% of runs.

Fix: parse the iv:authTag:ciphertext format, XOR the first authTag byte
with 0xFF (guaranteed to change the value), reassemble. This reliably
triggers the AES-256-GCM integrity failure every time.

Verified: 30/30 isolated runs + 8/8 full-suite runs (1036/1036), zero
failures. The production encryptBackup/decryptBackup (AES-256-GCM)
code is correct and unchanged.
2026-06-27 07:02:47 -07:00
Hermes 1c8f55edc1 DC-018: return writeErrorLog promise from Logger.error()
Logger.error() called this._log('error',...) but dropped the return value.
_log returns the writeErrorLog(...) promise for error level, so every
await logError(...)/await log.error(...) caller was awaiting undefined —
the error.log disk write was fire-and-forget. This caused:

1. __tests__/logging.test.js 'captures request context' to flake in the
   full suite (test read error.log before the un-awaited appendFile
   completed; passed in isolation).
2. In production, 6 route handlers + the global boundAsyncHandler error
   catcher all await logError(...) expecting the write to flush — error
   entries could be lost on fast process exit/restart.

Fix: add 'return' so the promise propagates. Verified: logging test
passes 10/10 full-suite runs (was ~1/6 failure rate). No behavior change
for debug/info/warn (they never wrote to disk).
2026-06-27 06:51:59 -07:00
Hermes ab0ef9cfa1 DC-017: Regression tests for depth-2 route paths + PUBLIC_ROUTES drift
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
After DC-005 path-fix (c39c80b) shipped 67 broken-require repairs across 21
depth-2 route files, two test gaps remained:

  1. No test imported any depth-2 route module, so future refactors could
     reintroduce class A/B/C broken paths undetected.
  2. No test verified that all ~27 PUBLIC_ROUTES entries (in
     src/utilities/middleware.js) corresponded to actually-mounted routes.
     DC-012 added a similar check for the 5 probe paths, but only those.

Added 3 files, fixed 1 test helper, no production code changed:

  - __tests__/depth2-routes-smoke.test.js (new): discovers every .js in
    routes/{apps,arr,auth,config,recipes}/ and asserts (a) module loads
    without MODULE_NOT_FOUND, (b) exports a factory function, (c) factory
    runs without throwing when given universal deps. Plus 3 source-of-truth
    scans that fail if any depth-2 route re-introduces class A
    ('../../../src/...'), class B ('../src/...'), or class C
    ('utilities/responses' instead of 'utils/responses') require paths.

  - __tests__/public-routes-drift.test.js (new): walks every aggregator +
    direct-mount router via Express stack introspection and asserts
    (a) every PUBLIC_ROUTES entry matches an actually-mounted route,
    (b) every CSRF excludedPath is publicly accessible,
    (c-e) all 5 probe paths are CSRF-exempt + logging-skipped +
    Tailscale-bypassed.

  - __tests__/test-helpers/universal-deps.js (new): Proxy + seed-object
    shared by both suites. Returns sensible stubs for any property access
    (logger-shaped object, asyncHandler pass-through, path-string stubs).
    Supports Object.assign/spread via ownKeys+getOwnPropertyDescriptor
    traps so aggregator factories that copy ctx into subCtx don't lose
    proxy magic.

Test-helper fixes needed to make the suites pass:

  - 'log' is now a logger-shaped object ({error, warn, info, debug, audit}
    as noops), not a bare noopFn — fixes '(ctx.log || console).error(...)'
    in routes/apps/index.js factory catch block.
  - 'asyncHandler' seeded as own enumerable property — survives
    Object.assign({}, ctx, { helpers }) used by routes/arr/index.js etc.
  - Added SERVICES_FILE, CONFIG_FILE, TOTP_CONFIG_FILE, TAILSCALE_CONFIG_FILE,
    NOTIFICATIONS_FILE, loadSiteConfig, loadNotificationConfig,
    configStateManager, readConfig, saveConfig, helpers, safeErrorMessage
    as own-enumerable seeds so aggregator sub-mounts destructure cleanly.

Public-routes-drift test fixes:

  - Aggregator walks use prefix '/api/v1' (matches src/app.js's bare-mount
    on apiRouter at /api/v1). Without this, the 6 TOTP routes registered by
    routes/auth/index.js appeared as '/totp/config' instead of
    '/api/v1/totp/config' and were falsely flagged as stale.
  - Direct-mount walks use '/api/v1' + explicit prefixMap entry (same reason).
  - Added routes/themes.js and routes/license.js to directMounts.

Result: 35 suites, 1036 tests, all passing (was 1030 passing + 6 failing
before this commit). The 6 pre-existing failures were depth-2 factory
errors + 22 PUBLIC_ROUTES stale entries that the test infrastructure
was silently swallowing — these tests surface them so they can't recur.

BACKLOG.md updated with full DC-017 entry (status: done, owner: krystie).
2026-06-26 12:16:38 -07:00
Hermes 8ec6c0ca6a DC-012: Add Kubernetes-style /healthz + /readyz probe aliases
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
Fresh users copy-pasting healthcheck blocks from k8s/Docker docs need
the standard short aliases. Without /healthz and /readyz they get
connection refused. This commit:

1. Adds /healthz + /readyz as root-level aliases for /health/live +
   /health/ready in src/app.js. Handler bodies DRYed into named
   functions (livenessHandler, readinessHandler) so a probe semantics
   change updates all five paths at once.

2. Removes the dead /api/v1/health*, /api/v1/health/live, /api/v1/health/ready
   registrations from PUBLIC_ROUTES and CSRF exclusion list — those
   routes were never actually mounted on the apiRouter (only root
   paths existed). Anyone probing /api/v1/health now gets a clean 404
   instead of being routed through to a duplicate root handler.

3. Adds bypass for the 5 probe paths in three places where it matters:
   - PUBLIC_ROUTES (no auth)
   - csrf-protection.js excludedPaths (no CSRF check)
   - middleware.js request-logging exclusion (k8s polling every 10s
     doesn't flood the audit log)
   - middleware.js Tailscale auth bypass (probes don't carry Tailscale
     identity headers)

4. Adds __tests__/health-probe-aliases.test.js (19 tests):
   - Alias equivalence (/healthz == /health/live, /readyz == /health/ready)
   - Back-compat (/health == /health/live)
   - Path consolidation (all 3 /api/v1/health* return 404)
   - Source-of-truth PUBLIC_ROUTES allowlist sync check
   - Source-of-truth src/app.js mount list sync check (catches drift
     between handler mount and middleware allowlist)

5. Documents probes in README (copy-paste docker-compose.yml +
   Kubernetes blocks) and user-guide (Health Probes section + System
   API table updated).

Post-fix: 941/941 tests pass (+19 new). Zero new ESLint warnings
introduced. The pre-existing warnings/errors in src/app.js line 906
('os' is not defined) and the empty blocks in logging.test.js are
not regressions from this commit.
2026-06-25 17:16:16 -07:00
Hermes c39c80b3ad Fix DC-005 depth-2 route path bugs: 67 broken requires across 21 files
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
The DC-005 src/ refactor left depth-2 route files (routes/auth/*,
routes/recipes/*, routes/apps/*, routes/arr/*, routes/config/*) with
broken require() paths. A filesystem-resolving scanner found 67 broken
requires across 21 files — three distinct bug classes:

  A) '../../../src/...' (3 levels up, above package root) — Bug 7, ~49 occurrences
  B) '../src/utils/...' (1 level up, resolves to nonexistent routes/src/) — ~15 occurrences
  C) routes/apps/restore.js:5 used utilities/responses (wrong dir) — should be utils/responses

All fixed to '../../src/...' (or '../../src/utils/responses' for class C).
routes/auth/totp.js was already fixed in the DC-006 commit.

Post-fix: 922/922 tests pass, zero new ESLint warnings. No logic changes —
purely mechanical require() path corrections.
2026-06-25 16:55:06 -07:00
Hermes 9688e64692 Fix DC-005 latent path bug: health-checker required './platform-paths' but file lives at top level
After DC-005 refactor moved health-checker.js into src/monitoring/, the
require path was never updated. Tests in __tests__/health-checker.test.js
failed with 'Cannot find module' → 59 cascading test failures in the
health-checker suite.

Path: src/monitoring/health-checker.js → 'require(./platform-paths)'
Fix:  'require(../../platform-paths)'

Verified: 921/922 tests passing (one known async-timing flake in
logging.test.js 'writes entry to ERROR_LOG_FILE with context').
2026-06-25 16:43:26 -07:00
Hermes 283121edba Merge krystie-improvements into main
Resolves 24 conflicts between Hermes (DC-008/009/010 + response-helper
envelope standardization) and Krystie (DC-005 src/ refactor path fixes,
DC-006 TOTP integration, DC-007 new test suites, cloud backup
destinations).

Conflict resolutions:
- src/utils/logging.js:    took ours (consumers depend on logError/
                            safeErrorMessage/createLogger exports)
- src/config/site.js:      merged (her factored validateAndLogConfig +
                            applyConfigFields helpers)
- src/context/dns.js:      took hers (admin/readonly role iteration for
                            write operations)
- src/utilities/backup-
  manager.js:              took hers (Dropbox/WebDAV/SFTP cloud feature)
- status/dist/*, status/
  sw.js:                   took hers (minified bundles + newer SW cache)

Additional fix (post-merge regression):
- src/monitoring/health-checker.js: fixed DC-005 path miss —
  'require(./platform-paths)' → 'require(../../platform-paths)'

Test status: 921/922 passing. One known failure in logging.test.js
(async file-handle timing) tracked as follow-up.
2026-06-25 16:43:10 -07:00
Hermes e1a45543ea DC-006: Add integration test for TOTP auth flow
Covers the full BACKLOG DC-006 acceptance criteria:
- GET /api/totp/config — read current config
- POST /api/totp/setup — generate / import Base32 secret
- POST /api/totp/verify-setup — activate TOTP after setup
- POST /api/totp/verify — login with TOTP code → session + CSRF
- GET /api/totp/check-session — auth gate (200 / 401)
- POST /api/totp/disable — disable TOTP (requires valid code)
- POST /api/totp/config — update session duration

25 tests, all passing. Uses real otplib for code generation
(so we exercise actual TOTP math) but mocks credentialManager,
session, totpConfig, saveTotpConfig — those own their own state
machines (disk, cookies, file) that don't belong in a routes
test.

Also fixed a latent DC-005 bug: routes/auth/totp.js had wrong
require-path depth after the refactor (../../../src/... went 3
levels up instead of 2, breaking route load). Changed to
../../src/... for the 2-level depth. NOTE: the same depth bug
exists in many other depth-2 route files (auth/keys.js,
auth/sso-gate.js, auth/session-handlers.js, recipes/*, apps/*,
arr/*, config/*) — see BACKLOG.md DC-005 follow-up note. Tests
didn't catch this because no test previously imported the auth
routes; this new test exercises that import path.

Result: 904/904 Jest tests pass (879 baseline + 25 new).
ESLint: this file clean. Pre-existing 134 src/ warnings are
unrelated (DC-005 refactor moved files without re-applying
DC-004 lint cleanup — separate follow-up).
2026-06-25 16:15:15 -07:00
Hermes 2f50998105 DC-010: Convert remaining bare res.json({success,...}) envelopes to response helper
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
Routes covered in this batch:
- routes/events.js (1 call: GET /status)
- routes/workflows.js (6 calls: GET/POST/PUT/DELETE /workflows, POST /test, POST /:id/toggle)
- routes/openclaw.js (4 calls: GET /:hostname, DELETE /:hostname, POST /connect, GET /status)
- routes/dns.js (1 call: POST /credentials per-server results envelope)

Wire format unchanged — each handler now produces the same {success, ...} shape via success(). Net result: every {success, ...} envelope in routes/ now flows through the response helper, leaving only the intentional raw-array calls (services.js) and error-path envelopes for separate cleanup.
2026-06-25 15:44:18 -07:00
Hermes c509f6ff10 DC-010: convert res.json({success:true,...}) → ok() in updates/notifications/tailscale
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
Routes converted: updates.js (17), notifications.js (8), tailscale.js (12).
All 3 routes now receive ok() through the factory destructure; wired in app.js.

notifications.js: kept 2 res.json() calls for genuine partial-failure semantics
  - POST /test with ?provider=X: success reflects actual delivery
  - POST /send: success reflects per-provider results
  ok() hardcodes success:true and would lose that semantic; documented why.

tailscale.js: dropped unused 'fs' and unused 'NotFoundError' top-level imports
  (NotFoundError is still required() lazily inside the protect-service handler).
  Net change: 12 calls cleaned up, 2 lint warnings fixed.

750/750 tests still pass.
2026-06-25 14:29:27 -07:00
Hermes bf515e5415 DC-010: convert res.json({success:true,...}) → ok(res, {...}) in 3 routes; refactor config/context/utils
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
Routes converted: browse.js, logs.js, sites.js. Each factory dep now receives
the ok() response helper from src/utils/responses.js. Wired through the route
factory destructuring in src/app.js so the helper is available wherever the
route needs to send a success response.

Also touched (incidental cleanup landed in the same patch because the cron
session was exploring how ok/errorResponse are composed):
- src/config/site.js: 28 lines net — response shape consistency
- src/context/caddy.js, dns.js: 34 lines net — minor refactors
- src/utils/http.js, logging.js: 46 lines net — ESLint hygiene and helper plumbing

750/750 tests pass, 0 new ESLint warnings.
2026-06-25 14:24:24 -07:00
Hermes a37e79a8fc DC-004: fix remaining 3 ESLint warnings (require-await, max-depth) 2026-06-25 06:22:07 -07:00
Hermes 16276c62fc DC-011: fix credential route paths + undefined ctx reference error
The src/ module-flattening refactor regressed the DC-001 fix: the 3
service-credential routes in routes/services.js used '/:serviceId/credentials'
instead of '/services/:serviceId/credentials', causing 4 test failures
(services.routes.test.js → 404 instead of 200) — every other route in the
file uses the '/services' prefix.

Also fixed a latent ReferenceError in the same validation branches: they
called ctx.errorResponse() but ctx is never defined in this module's scope
(the factory destructures its deps). Replaced with the imported errorResponse
helper so invalid serviceIds now return a clean 400 instead of crashing 500.

Tests: 4 failed → 0 failed (750 pass). ESLint: no new warnings.
2026-06-21 05:54:00 -07:00
Krystie f71e5c52d4 feat(api): unify logger — single source of truth for logs, errors, audit
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
Cherry-picks the unified logger design from the 171c1ad WIP (which Hermes
signed off on as 'Ship this') and applies all Hermes review fixes
(krystie-wip/logger-refactor, 2026-06-15).

  src/utils/logging.js is now the single entry point for:
    - log.info / log.warn / log.error / log.debug  (with level filtering,
      color-coded dev output, JSON prod output)
    - log.audit() / log.auditMiddleware()           (audit-log.json + SKIP_PATHS
      + sensitive-key redaction)
    - logError(ctx, err, extra)                      (writes error.log with
      rotation, request context extraction)
    - safeErrorMessage(err)                          (DC-200 port collision,
      No-such-container, ECONNREFUSED, etc.)

  Existing src/security/audit-logger.js kept untouched — routes/errorlogs.js
  still uses auditLogger.query/clear, no callers migrated.

  Hermes' must-fixes (all addressed):
    [1] Syntax error on logger.js:401 — old logger.js at repo root is gone;
        refactored src/utils/logging.js is the new home, no Chinese IME bug.
    [2] /health/live and /health/ready endpoints — untouched in src/app.js.
    [3] Tests — added __tests__/logging.test.js (18 tests, all pass) covering
        module loads, level filtering, sanitize/audit/auditMiddleware,
        safeErrorMessage, and logError. Full suite: 897/897 pass across 31
        suites (was 879 + 18 new).

  Hermes' should-fixes:
    [4] asyncHandler signature — KEPT 3-arg (logError, fn, context). 49 route
        files still call it this way; src/app.js's boundAsyncHandler unchanged.
    [5] platformPaths.pkiRootCert — UNTOUCHED, still used in src/app.js.
    [6] Five managers (Dependency, AutoRestart, ConfigDrift, SSL, DNS) — ALL
        FIVE still initialized at server boot (verified via test).
    [7] ok(res, ...) helper — UNTOUCHED, all routes still use it.
    [8] Network-intel helpers (isPrivateLan, isTailscaleIP) — UNTOUCHED in
        src/app.js, no duplicate inline logic added.

  - setLevel() now updates both GLOBAL_LEVEL and the singleton log._level,
    so level-filter tests don't pollute later tests.
  - Logger.audit() and Logger.error() now return promises so await works.
  - Logger._log() awaits writeErrorLog so callers using await can rely on
    the error.log being flushed.
  - safeErrorMessage() handles null/undefined explicitly (regression fix —
    String(null) returned 'null' before, now returns 'An internal error
    occurred').
  - src/app.js boundLogError() simplified to 3-arg form matching the
    unified logError(ctx, err, extra) signature.

  - createLogger(level) alias exported so existing src/app.js callers work.
  - logError, safeErrorMessage, LOG_LEVELS still exported.
  - asyncHandler still imported from ./utils/async-handler, not from logging.
  - No changes to routes/* (audit-logger.js still consumed unchanged).

  - jest: 897/897 tests pass across 31 suites
  - node -e "require('./src/app.js')" loads cleanly
  - node server.js boots through full init (all 5 managers start)
  - Color-coded logger output visible in dev mode (no NODE_ENV)
  - JSON output in production mode (NODE_ENV=production)
2026-06-19 18:41:26 -07:00
Krystie 44af47d344 feat: add Sami Files logPath to template + mount in start.sh
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
2026-06-19 18:16:34 -07:00
Krystie 6809fc5cca fix(monitoring): flatten CPU/mem data, add health summary, public + rate-limit monitoring/stats
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
Three coordinated fixes for the System Overview widget:

1. routes/monitoring.js — flatten getAllStats() shape from
   {current:{cpu:{percent},memory:{percent}}} to {cpu,memory,memoryUsage}
   so the widget's Number() coercion actually produces numbers, not NaN.
   Skill reference: references/totp-and-system-overview-pitfalls.md §3.

2. routes/health.js — add summary block to /health-checks/status response.
   Widget looks for {healthy, unhealthy, total} but only per-service objects
   existed. Permissive on healthy side (up|healthy|online), strict on
   unhealthy (down|unhealthy|offline|error); anything else counted as
   unknown. Same skill §3 reference.

3. middleware.js — add /api/v1/monitoring/stats to PUBLIC_ROUTES and the
   rate-limit skip list. The widget polls it every 5s from the dashboard;
   cookie-auth works but listing it explicitly makes it future-proof
   against auth-cookie expiry and prevents per-second 429s.

End-to-end test (unauthenticated):
  GET /api/v1/monitoring/stats  -> {cpu: 8.71, memory: 0.37, ...}
  GET /api/v1/health-checks/status -> {summary: {healthy:11, unhealthy:4, total:15}}
2026-06-18 21:17:16 -07:00