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.
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.
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).
- 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).
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.
Captures the work done in this session (DC-033) and surfaces 9 follow-up
items that came out of the cross-check investigation:
P1: DC-034 (regenerate release tarball as 1.14.9), DC-035 (regression test
for getLocalVersion), DC-036 (delete dead root self-updater.js), DC-037
(move symlink creation into install script so fresh hosts don't repeat
the v1.14.4 failure mode).
P2: DC-038 (backup trigger.json/result.json), DC-039 (audit for other
__dirname antipatterns), DC-040 (audit whether post-deploy-patches.sh is
still needed), DC-041 (integration test for the auto-update pipeline).
Each ticket cites the specific files, commit SHAs, and evidence from
this session so future agents can pick up where this left off.
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).
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.
The container's health-checker runs against Caddy via /etc/hosts resolution.
The node:20-alpine base image has no entries for *.sami, so without explicit
--add-host flags every *.sami probe resolves via the configured DNS server
(100.121.150.22 Technitium or 8.8.8.8) — both of which DO resolve *.sami but
return the WAN/Tailscale IP. That works for most services because Caddy on
DNS2:443 handles them.
BUT: a previous container run passed --add-host=git.sami:100.81.59.99
(DNS3's Tailscale IP). DNS3 does NOT serve HTTPS on 443 — Gitea listens on
:3030 only. So git.sami health checks inside the container hit DNS3:443,
get ECONNREFUSED, and the dashboard shows git.sami as down even though Caddy
on DNS2:443 correctly routes git.sami → 100.81.59.99:3030.
Fix: inject the correct --add-host flags from start.sh (the source of truth
for container setup) so future recreates get consistent resolution. git.sami
is intentionally left OUT — Caddy on DNS2:443 is the only correct ingress
for git.sami traffic.
Also documents the rationale so the next person doesn't reintroduce the
git.sami override by accident.
Live verified:
- container /etc/hosts has all needed entries except git.sami
- curl https://git.sami/ from inside container → 200 (via Caddy on :443)
- curl https://sync.sami/ from inside container → 302 (upstream redirect)
- curl https://router.sami/ from inside container → 302 (upstream redirect)
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.
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.
[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).
The host-side updater has been silently broken in two ways:
1. Empty staging directories would cause rm -rf of live routes/src with no
replacement, leaving the host tree gutted while the container kept serving
from its own image. Now deploy_tree() refuses to delete unless the staging
source has actual files.
2. chattr +i on critical files (used to protect security-hotfixed routes from
being clobbered by upstream tarballs) caused rm -rf to partially execute
then fail under set -e, leaving the host in a half-deleted state. Now
deploy_tree() scans for immutable files, unlocks them before replace,
and re-locks them after — so security-locked files survive every update.
Also adds:
- Channel gate: trigger.json channel=prerelease/beta/rc/alpha is rejected
unless ALLOW_PRERELEASE=true is set in /opt/dashcaddy/updates/channel.conf.
Default is 'stable only', safe for production. Staging hosts opt in.
- channel.conf.example documenting the new opt-in mechanism.
Verified end-to-end: manual trigger.json → path unit fired → routes (53 files)
+ src (62 files) deployed → container rebuilt → health check passed. totp.js
remained locked with security edits intact.
- dashcaddy-installer/install.sh: 1.1.0 → 1.14.6 (matches current release)
- dashcaddy-api/VERSION: 10f72af → a5f51e4 (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).
- 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)
The release tarball previously omitted dashcaddy-api/src/, which meant the
in-container self-updater had to apply post-deploy patches (dashcaddy-post-
deploy-patches.sh) to work around missing files. That script generates 37
flat copies of src/ files at the dashcaddy-api/ root level to satisfy
broken require() paths. With proper src/ shipping, those files become
obsolete, but they were still being shown as untracked in git.
Changes:
- BUILD-PIPELINE-FIX.md documents the build pipeline fix (in /opt/dashcaddy-release/
build-release.sh — sibling repo, not tracked here)
- .gitignore now ignores the 37 generated post-deploy artifacts plus the
backups/ and updates/ runtime directories, so 'git status' stays clean
- scripts/dashcaddy-post-deploy-patches.sh is now tracked so it's preserved
across rebuilds (still useful as a safety net for transitional installs)
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]
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.
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.
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.
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>
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.
- 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>
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.
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).
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).
Picked up the four 'Still Open' standardization items from the audit doc
as Option B work. Audited each before starting implementation:
DC-013 (config schema migration) — src/config/migrations.js exists with
a versioned migration system (CURRENT_VERSION=2, v1 dns normalization,
v2 dns.provider field), loadAndMigrate() writes back to disk only when
version changes, called from src/config/site.js on every startup.
Guarded by 21 tests in __tests__/config-migrations.test.js.
DC-014 (monitoring endpoint opt-in) — MONITORING_PUBLIC env var +
config.monitoring.public both work via an IIFE in
src/utilities/middleware.js line 297. Routes are conditionally public
based on the flag. Default is 'true' for back-compat with existing
dashboards that pre-load widget data. Flipping the default to 'false'
is a fresh change with a real UX cost.
DC-015 (CSRF token path duplication) — grep confirms only
/api/v1/csrf-token exists. /api/v1/auth/csrf-token was never
implemented or was already cleaned up.
DC-016 (per-call fetchT timeouts) — src/utils/http.js defines
fetchT(url, opts, timeoutMs) with AbortSignal.timeout() in the
native branch and explicit timeout handlers in the http/https
raw-request branches. 5s default covers most calls; 8 of 77 sites
pass explicit overrides. 5min global request timeout is the backstop.
All four tasks reassigned from krystie → hermes because the work shifted
from 'implement' to 'verify and document'. No code changes in this commit
— only BACKLOG.md and CHANGELOG.md updated to reflect actual state.
This commit is the meta-example for Pitfall 20 (just added to the
standardization pitfalls reference): audit docs decay as fast as fixes
land. Always audit before implementing.