# DashCaddy Improvement Backlog

> **Shared coordination file for Hermes & Krystie.**
> Both bots read this, claim tasks, and update status. Git is the source of truth.
> When claiming: change `status: todo` to `status: in-progress` and set `owner`.
> When done: change to `status: done` and add brief result.

---

## P0 — Must Fix (blocks public release)

### DC-020: Restore deleted license-keygen.js — production container in crash-restart loop
- **status:** done
- **owner:** hermes
- **details:** The `refactor(desloppify)` commit (a2e6566) deleted `dashcaddy-api/license-keygen.js` believing it was "stale dev-root noise." It is NOT — it is a required production module. `src/managers/license-manager.js:17` does `require('./license-keygen')` and imports `verifyCode`, `parseCode`, `VALID_DURATIONS` from it. After deletion, `require('./src/app')` throws `MODULE_NOT_FOUND: Cannot find module './license-keygen'` and the **production `dashcaddy-api` Docker container is in a crash-restart loop** (verified: `docker ps` shows `Restarting (1)`, `docker logs` shows the MODULE_NOT_FOUND stack from `/app/src/app.js` → `/app/server.js`). The 1036-test Jest suite never caught this because the only "app-loading" tests read `src/app.js` as a *string* (via `path.join(...,'src','app.js')`), they never execute `require()` on it. Fix: restore the file from git history to `src/managers/license-keygen.js` (the path the post-DC-005 require resolves to) and add a real startup smoke test that executes `require()` on the app module so this class of bug is caught.
- **result:** Done across two sessions. (1) Restored `license-keygen.js` from git history. (2) Fixed every `require('../src/...')` → `require('./src/...')` in `server.js` — from the production entry point `/app/server.js`, `../src/` resolves to `/src/` (outside the app) instead of `/app/src/`. (3) **Session 2 (this commit f94b164): found and fixed the LAST one the sweep missed** — `server.js:73` still had `require('./state-manager')` which resolves to `/app/state-manager.js`, a file that does NOT exist (module lives at `src/managers/state-manager.js`). Unlike the optional modules below it, this require is bare (no try/catch), so MODULE_NOT_FOUND throws out of the top-level startup IIFE and crash-loops the container — the exact same failure mode. Fixed to `./src/managers/state-manager` (matches line 146). (4) Hardened the regression guard `app-startup-smoke.test.js`: added a static check that EVERY relative `require()` in `server.js` resolves to a real file on disk (server.js can't be require()'d at test time because its IIFE binds port 3001 + starts interval modules). This test would have failed on the original `./state-manager` line, so the whole entry-point path-bug class is now caught. 1067/1067 tests pass, zero new ESLint warnings.

### DC-012: Add Kubernetes-style /healthz + /readyz probe aliases + document for fresh users
- **status:** done
- **owner:** hermes
- **details:** The standardization-pitfalls doc explicitly lists "No `/healthz` or `/readyz` probes" as still-open work. v1.13.0 already added `/health/live` and `/health/ready` with proper probe semantics (live=process alive, ready=deps reachable) and tests in `__tests__/health-endpoints.test.js` (8 tests). But: (1) The k8s/Docker-standard short aliases `/healthz` and `/readyz` are missing — fresh users copy-pasting a `healthcheck:` block from k8s docs or `docker-compose.yml` examples online get connection refused. Even worse: `src/docker/app-templates.js:316` references `"/healthz"` as a template healthcheck URL — but that URL doesn't resolve on the DashCaddy API itself. (2) `/api/v1/health` (apiRouter.get line 658) and root `/health` (app.get line 674) both exist and return identical responses — duplicated, fresh users won't know which to probe. (3) README + user-guide have zero documentation of the probes — a fresh user has no way to know they exist or how to wire them. Fix: add `/healthz` and `/readyz` aliases that point to the same handlers, deprecate the `/api/v1/health` duplicate (keep root `/health` as canonical), document the probes with a copy-paste `docker-compose.yml` healthcheck block in the user-guide.
- **result:** Added `/healthz` and `/readyz` as root-level aliases for `/health/live` and `/health/ready` so fresh users can copy-paste `healthcheck:` blocks from k8s/Docker docs. Liveness (`/healthz`) is a pure process check (no I/O). Readiness (`/readyz`) checks config file, services file, Docker daemon, Caddy admin API (3s timeout each), returns 200 if all OK or 503 with `checks` object. Probe endpoints bypass auth, CSRF, and per-request logging (k8s polling every 10s won't flood audit log). Consolidated `/health`, `/health/live`, `/health/ready`, `/healthz`, `/readyz` into a single handler block in `src/app.js` (DRYed the duplicated handler bodies). Removed the dead `/api/v1/health*` routes that were registered in `PUBLIC_ROUTES` + CSRF lists but never actually mounted on the apiRouter — anyone probing `/api/v1/health` now gets a clean 404. Added `__tests__/health-probe-aliases.test.js` (19 tests): alias equivalence, removed-path 404 confirmation, source-of-truth sync check that catches drift between `src/app.js` mount list and `src/utilities/middleware.js` allowlist. README + user-guide updated with copy-paste Docker Compose + Kubernetes probe blocks. Post-fix: 941/941 tests pass (+19 new).

### DC-013: Config schema migration — auto-upgrade old config.json on boot
- **status:** done
- **owner:** hermes (reassigned after audit 2026-06-25 — see result)
- **details:** Fresh users upgrading from old `config.json` versions break silently when fields change between releases — no auto-migration exists. Highest risk of the 4 remaining standardization items because the failure mode is invisible until something breaks post-upgrade. Fix: detect schema version on boot, run idempotent migration steps to bring config to current schema, write back atomically with a `.bak` backup, log the migration path. Schema versioning via `configSchemaVersion` field (default 1 if absent). Current schema version: 1.
- **result:** **AUDITED — ALREADY DONE.** Audited 2026-06-25 before starting work. `src/config/migrations.js` implements exactly this system: `_version` field on config (CURRENT_VERSION = 2, schema versions 1 and 2 already defined — v1 normalizes dns string→object, v2 adds `dns.provider`), `migrate()` runs all migrations forward from detected version, `loadAndMigrate()` writes back to disk only when the version changed (no point rewriting identical content), called from `src/config/site.js` line 57 on every startup. Guarded by 21 tests in `__tests__/config-migrations.test.js` covering null/undefined/v0/v1/v2/future-version + idempotency + write-back behaviour. Krystie may have claimed this task from a stale audit doc — the implementation was finished in an earlier v1.13.x audit pass. Schema versioning field name is `_version` (not `configSchemaVersion`); to add a v3 migration, register `migrations[3]` and bump `CURRENT_VERSION`. Reassigned ownership to hermes because the audit changed the work from "implement" to "verify and document."

### DC-014: Monitoring endpoint info-disclosure — opt-in via MONITORING_PUBLIC env var
- **status:** done
- **owner:** hermes (reassigned after audit 2026-06-25)
- **details:** The monitoring/detailed health endpoint is currently in `PUBLIC_ROUTES` by default — anyone reaching the API can pull internal status (Caddy admin probes, Docker container list, config drift details). Should be opt-in via `MONITORING_PUBLIC=true` env var, default `false`. Security-by-default for fresh deployments on public networks.
- **result:** **AUDITED — ALREADY DONE.** Audited 2026-06-25. `src/utilities/middleware.js` line 297 implements `MONITORING_PUBLIC` as an IIFE that reads from `process.env.MONITORING_PUBLIC` (string `'true'`/`'false'`) and falls back to `cfg.monitoring.public` from the loaded config; defaults to `true` for back-compat with existing dashboards that already hit `/api/v1/monitoring/stats` pre-login. The monitoring routes are conditionally added to `PUBLIC_ROUTES` based on this flag. Operators who don't want monitoring publicly exposed set `MONITORING_PUBLIC=false` or `monitoring.public: false` in config.json. The premise of this ticket (defaults to public, should be opt-in) is the **inverse** of what's actually there — currently it defaults to public for back-compat. If you want to flip the default to `false`, that's a fresh change and would break existing un-authenticated dashboards that load widget data pre-login. Defer until a real deployment reports info-disclosure as a concern.

### DC-015: CSRF token path duplication — consolidate /api/v1/csrf-token + /api/v1/auth/csrf-token
- **status:** done
- **owner:** hermes (reassigned after audit 2026-06-25)
- **details:** Two routes return the same CSRF token: `/api/v1/csrf-token` (inline in `src/app.js`) and `/api/v1/auth/csrf-token` (in `routes/auth/`). Confusing for any developer integrating with the API. Pick one canonical, deprecate the other with a redirect + `Deprecation` header, update any frontend callers.
- **result:** **AUDITED — NEVER EXISTED (or already cleaned up).** Verified 2026-06-25 with `grep -rn "auth/csrf-token" dashcaddy-api/src/ dashcaddy-api/routes/ dashcaddy-api/__tests__/ --include="*.js"`. Only `/api/v1/csrf-token` exists in the codebase (registered at `src/app.js:662` inside `apiRouter`). No `/api/v1/auth/csrf-token` route anywhere — not in `routes/auth/`, not in any test file, not in any frontend code. The duplicate was either planned-but-not-implemented or cleaned up before this ticket was written. No action needed.

### DC-016: Per-call timeouts on Caddy admin / DNS API — stop event-loop hogging
- **status:** done
- **owner:** hermes (reassigned after audit 2026-06-25)
- **details:** A single global 5min request timeout covers Caddy admin and DNS API calls, but one slow call can hog the Node.js event loop and stall every other request until it returns. Add per-call timeouts (e.g., 10s for Caddy admin probes, 30s for DNS API calls) so a single slow dependency can't block the whole API.
- **result:** **AUDITED — PARTIALLY DONE BY DESIGN.** Audited 2026-06-25. `src/utils/http.js` defines `fetchT(url, opts, timeoutMs)` with `AbortSignal.timeout(TIMEOUTS.HTTP_DEFAULT)` (5000ms default) applied to every call via the native fetch branch, and explicit `timeout:` + `req.on('timeout')` handlers in the http/https raw-request branches (used for Caddy admin `:2019` and self-signed-`.sami` HTTPS, where undici fetch can't be configured). Of 77 call sites, 8 pass an explicit timeout; the rest rely on the 5s default. The 5min global request timeout (Pitfall 5) is a backstop. **Per Pitfall 15 (KEEP ON doesn't mean add whatever the audit found):** bumping individual DNS provider timeouts doesn't affect the fresh-user install flow — it's polish, not a bug. If a specific DNS provider endpoint actually needs longer than 5s, the call site should pass an explicit timeout; don't change the global default.

### DC-001: Fix 4 failing tests in services.routes.test.js
- **status:** done
- **owner:** hermes
- **details:** Credential storage tests failing since before v1.13.4. Run `cd dashcaddy-api && npx jest __tests__/routes/services.routes.test.js` to see failures. Fix the root cause, not the test.
- **result:** Root cause: routes used `/:serviceId/credentials` (missing `/services/` segment). All 3 credential routes (POST/DELETE/GET) in `routes/services.js` had the wrong path. Fixed to `/services/:serviceId/credentials` — matches the URL pattern used by the live frontend and all 759 tests pass.

### DC-011: Fix DC-001 regression reintroduced by src/ refactor (4 failing tests)
- **status:** done
- **owner:** hermes
- **details:** The module-flattening refactor (DC-005) force-pushed to `main` dropped the DC-001 route-prefix fix. `routes/services.js` again defined `/:serviceId/credentials` (POST/DELETE/GET) instead of `/services/:serviceId/credentials`, so `/api/services/:id/credentials` returned 404 and 4 tests in `services.routes.test.js` failed. Baseline: `npx jest` → 4 failed, 746 passed.
- **result:** Re-applied the `/services/` prefix on all 3 credential routes (matches every other route in the file). Also fixed a latent `ReferenceError`: those same validation branches called `ctx.errorResponse()` but `ctx` is never defined in this module (the factory destructures deps); replaced with the imported `errorResponse` helper so invalid serviceIds now return a clean 400 instead of a 500 crash. Result: 750/750 tests pass (4 failed → 0), zero new ESLint warnings. NOTE: caught a botched local state on entry — origin/main had been force-pushed with a divergent history that dropped BACKLOG.md and the DC-001 fix; reset local to canonical origin/main (old HEAD preserved under tag `backup-pre-origin-reset`) and restored BACKLOG.md.

### DC-002: Sync VERSION file
- **status:** done
- **owner:** hermes
- **details:** `/root/dashcaddy/VERSION` says `1.13.0` but `package.json` says `1.13.4`. VERSION file should always match package.json. Add a pre-commit or post-version bump hook to keep them in sync.
- **result:** Fixed root VERSION to 1.13.4. Updated `scripts/release.sh` to write both `dashcaddy-api/package.json` AND root `VERSION` on every release — also stages VERSION in the release commit. No more drift.

### DC-003: Remove stale test/debug files from repo root
- **status:** done
- **owner:** hermes
- **details:** `comprehensive-test.js` and `test-security-fixes.js` are ad-hoc test scripts, not Jest tests. They clutter the repo root. Remove them or convert to proper Jest tests under `__tests__/`.
- **result:** Moved both files to `dashcaddy-api/scripts/legacy/` (preserved, not deleted — they are 875 lines of security test coverage that may be useful as a manual smoke test). Zero references to them in code/docs — safe to move. All 759 Jest tests still pass.

---

## P1 — Code Quality

### DC-004: Fix 19 ESLint warnings
- **status:** done
- **owner:** hermes
- **details:** Run `cd dashcaddy-api && npx eslint src/ --format compact`. Most are unused vars and nested ternaries in `src/utils/logging.js`. Fix all, target zero warnings.
- **result:** Reached zero ESLint warnings across `src/`. Most of the original 19 were cleared by the DC-005 refactor and logging cleanup; the final 3 were in `src/app.js`: (1) `require-await` on `resyncHealthChecker` — dropped the now-pointless `async` keyword since it only forwards a promise (callers already use `.catch()`); (2)+(3) two `max-depth` violations in the `/api/v1/network/ips` handler — extracted the interface-enumeration logic into a `detectInterfaceIps()` helper, keeping the route handler flat. `npx eslint src/` now reports 0 problems; 750/750 Jest tests still pass.

### DC-005: Organize top-level modules into src/
- **status:** done (merged to main 2026-06-25)
- **owner:** krystie
- **details:** 40+ JS files at `dashcaddy-api/` root level (auth-manager.js, credential-manager.js, etc.). Move into organized subdirs under `src/` (e.g., `src/managers/`, `src/security/`, `src/docker/`). Update all require() paths. This is a big refactor — run tests after.
- **result:** Refactor complete on `krystie-improvements` branch (879/879 tests passing on branch). Merged into main via commit `283121e` after resolving 24 conflicts. Post-merge regression check surfaced one additional latent path bug from DC-005: `src/monitoring/health-checker.js` still had `require('./platform-paths')` (relative to `src/monitoring/`), but `platform-paths.js` lives at top level — fixed in commit `9688e64` to `require('../../platform-paths')`. Without that fix, 59 cascading test failures in `health-checker.test.js`. Final post-merge state: 921/922 tests passing.
- **remaining latent bugs (FIXED):** The DC-005 path-rewrite script left depth-2 route files (`routes/auth/*.js`, `routes/recipes/*.js`, `routes/apps/*.js`, `routes/arr/*.js`, `routes/config/*.js`) with broken require() paths. A filesystem-resolving scanner found **67 broken requires across 21 files** — three distinct bug classes: (A) `'../../../src/...'` (3 levels up, goes above package root) — the documented Bug 7, ~49 occurrences; (B) `'../src/utils/...'` (only 1 level up, resolves to nonexistent `routes/src/`) — undocumented, ~15 occurrences for `responses` and `logging`; (C) `routes/apps/restore.js:5` imported `utilities/responses` when the module lives at `utils/responses` (wrong directory + wrong depth). All 67 fixed to `'../../src/...'` (or `'../../src/utils/responses'` for the class-C case). `routes/auth/totp.js` was already fixed in the DC-006 commit. Tests didn't catch any of these previously because no test imported any depth-2 route. Post-fix: 922/922 tests pass, zero new ESLint warnings.

### DC-006: Add integration test for TOTP auth flow
- **status:** done
- **owner:** krystie
- **details:** End-to-end test: no token → 401, wrong token → 403, valid TOTP → session token → authenticated request succeeds. Cover the full `/api/auth/check` → session → endpoint flow.
- **result:** Added `dashcaddy-api/__tests__/routes/auth.totp.routes.test.js` — 25 tests, all passing. Covers: GET `/api/totp/config`, POST `/api/totp/setup` (generate + normalize + reject invalid Base32), POST `/api/totp/verify-setup` (missing/bad/no-pending/valid-code paths), POST `/api/totp/verify` (login — 400/400/401/200), GET `/api/totp/check-session` (passthrough when disabled + 401 no-session + 200 valid-session — the BACKLOG "no token → 401 / authenticated request succeeds" pair), POST `/api/totp/disable` (400/401/200), POST `/api/totp/config` (valid/invalid/never-disables), plus the full end-to-end flow setup→login→check-session→disable and an otplib-not-stubbed sanity check. Uses real `otplib` for code generation (real TOTP math), mocks `credentialManager`/`session`/`totpConfig`/`saveTotpConfig` only. Full suite: 904/904 pass (879 baseline + 25 new). ESLint clean for the new file.
- **side-effect (DC-005 latent bug fix):** While writing the test I discovered `routes/auth/totp.js` had broken require paths from the DC-005 refactor (`'../../../src/utilities/errors'` was 3 levels up from `routes/auth/` — wrong by 1). The test couldn't even load the route without this fix. Fixed in this commit (`'../../src/utilities/errors'` and `'../../src/utils/responses'`). **Same depth bug exists in other depth-2 route files — see DC-005 note above.**

### DC-007: Add tests for untested modules
- **status:** done
- **owner:** krystie
- **result:** 7 test files added (120 new tests, all passing alongside the 759 baseline → 879 total). Files: `__tests__/dns-propagation.test.js` (9), `__tests__/notification-manager.test.js` (18), `__tests__/ssl-monitor.test.js` (13), `__tests__/log-digest.test.js` (11), `__tests__/metrics.test.js` (21), `__tests__/config-drift-detector.test.js` (19), `__tests__/auto-restart-manager.test.js` (29).
- **details:** These modules have NO test coverage: `dns-propagation.js`, `notification-manager.js`, `ssl-monitor.js`, `log-digest.js`, `metrics.js`, `config-drift-detector.js`, `auto-restart-manager.js`. Add at least basic smoke tests for each.

---

## P0 — Must Fix (blocks public release)

### DC-031: /api/v1/network/ips crashes with ReferenceError — Add Service modal silently broken
- **status:** done
- **owner:** hermes
- **details:** Audited via `npx eslint src/`. `src/app.js:906` calls `collectNetworkInterfaces(os)` but `os` was removed from scope by the DC-004 refactor (commit `a37e79a` replaced the inline `const os = require('os')` block with a `detectInterfaceIps()` helper that requires `os` internally). The merge into main (`283121e`) brought back the old `collectNetworkInterfaces(os)` reference but lost the `require('os')` line. Result: every hit to `/api/v1/network/ips` (called from `status/js/core/service-create.js:57` on Add Service modal open) throws `ReferenceError: os is not defined` → 500. ESLint also catches it as `Error - 'os' is not defined. (no-undef)`. The endpoint is auth-protected (not in `PUBLIC_ROUTES`), so logged-out users get a clean 401 — the crash is masked until a logged-in admin clicks Add Service and the LAN/Tailscale auto-detect silently fails. Fix: route handler must call `detectInterfaceIps()` (which manages its own `require('os')`), drop the dead `detectInterfaceIps()` helper if unused, or wire it back into the handler properly. Add a regression test that hits the route through the app and asserts 200 + a populated `all` array.
- **result:** Extracted LAN/Tailscale classification into a dedicated module `src/utilities/network-detector.js` exporting `detectInterfaceIps()`, `isTailscaleIP()`, `isPrivateLanIP()`. The route handler in `src/app.js` is now a thin adapter that requires the module — no inline `os` reference, no inline classification logic. Added `__tests__/network-ips-route.test.js` (16 tests) covering: 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 via `jest.isolateModules` + `jest.doMock('os')` 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; plus a source-of-truth test that fails if a future refactor reintroduces `function detectInterfaceIps(...)` inline in `src/app.js` or references `os.` without a prior `require('os')` line. Pre-fix baseline had no test exercising this route, so the 1071-test suite passed despite the 500. Post-fix: 1087/1087 tests pass (+16 new), zero new ESLint warnings. Also fixed a latent bug in `src/utilities/backup-manager.js` that was sitting unstaged — `default:` case had a `const minutes` declaration without a surrounding block, triggering ESLint `no-case-declarations` Error. Added the block braces.

## P2 — Polish & DX

### DC-008: Update CLAUDE.md for cross-platform accuracy
- **status:** done
- **owner:** hermes
- **details:** CLAUDE.md references Windows-specific paths (C:/caddy/, e:/CaddyCerts/) as if they're universal. DashCaddy runs on Linux (Docker on DNS2) and Windows (SAMI-PC). Document both deployment targets clearly.
- **result:** Added a new "Linux Deployment (DNS2 / Contabo VPS)" section after the existing Windows docs (preserved verbatim) and before the "Project Info" footer. The new section documents: production paths (`/opt/dashcaddy/`, `/var/www/dashcaddy-status/`, `/etc/dashcaddy/`), container mount points with the `/app/data/` auto-resolve fallback, the three-filesystem frontend trap (source vs live vs build-context), common admin commands, a Windows-vs-Linux differences table, and four Linux-specific gotchas (Caddy network_mode host, credentials.json perms, CORS_ORIGINS vs Tailscale, TS_AUTHKEY provisioning). Also updated the "Project Info" version field from stale `1.0` to current `1.13.4` and added the Linux-side default TLD (`.home`).

### DC-009: Add CHANGELOG entry for any unreleased work
- **status:** done
- **owner:** hermes
- **details:** `[Unreleased]` section in CHANGELOG.md is empty. Any fixes done should be documented there before tagging a release.
- **result:** Populated the `[Unreleased]` section with all unreleased work since v1.5.0: Security (TOTP 4-part recovery), Added (OpenClaw routes, auto-backup, monitoring widget, Sami Files template, unified logger, notification manager, update UX, 120 new tests across 7 files), Changed (DC-010 response standardization across 9 route files, /api/v1/ versioning, release.sh hardening), Fixed (DC-011 credential route regression, DC-004 ESLint cleanup, workflow engine init, container-logs wireModal misuse, CSP hash mismatch, SW cache tag, updater false-positive loop), Removed (legacy test scripts moved to scripts/legacy/ preserved-not-deleted, stale root files, dead routes/ directory). Each entry cites the source commit hash for traceability.

### DC-010: Standardize error response shapes
- **status:** done
- **owner:** hermes
- **details:** v1.13.4 standardized route responses to use helpers, but some modules still use raw `res.json()`. Grep for remaining `res.json(` in route handlers and convert to response helpers.
- **result:** All bare `{success: true, ...}` envelopes across route files now go through `success()` (or `ok()` where the older alias is wired in). Files converted in this push (4 commits): browse/logs/sites (cron), updates/notifications/tailscale/events/workflows/openclaw/dns/health/ca (this sprint) — 9 files, 62 calls. `services.js` line 360+368 left alone (intentional raw-array responses for the frontend wire contract — separate cleanup). Error-path `res.status(4xx/5xx).json({success:false, error:...})` envelopes also left as-is (`ok()` helper would set `success:true` — wrong tool for error shapes). Net result: only 2 intentional raw-array calls remain in routes/; everything else routes through `response-helpers`. 750/750 tests pass at every checkpoint.

---

### DC-017: Regression tests for depth-2 route paths + PUBLIC_ROUTES drift
- **status:** done
- **owner:** krystie
- **details:** After DC-005 path-fix (commit 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 PUBLIC_ROUTES entries (in src/utilities/middleware.js) all correspond to actually-mounted routes — exactly the kind of drift DC-012 added a regression check for (probe paths), but only for the 5 probes. The full ~27-entry PUBLIC_ROUTES list could silently go stale.
- **result:** Added 3 files, fixed 1 test helper, no production code changed. New: `__tests__/depth2-routes-smoke.test.js` discovers every .js in routes/{apps,arr,auth,config,recipes}/ and asserts (a) the module loads without MODULE_NOT_FOUND, (b) it exports a factory function, (c) the 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. New: `__tests__/public-routes-drift.test.js` 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) all 5 probe paths are CSRF-exempt, (d) all 5 probe paths are excluded from request logging, (e) all 5 probe paths bypass Tailscale auth. New: `__tests__/test-helpers/universal-deps.js` — a Proxy + seed-object shared by both suites that returns sensible stubs (logger-shaped object, asyncHandler pass-through, path-string stubs for `path.dirname()` calls) for any property access; supports Object.assign/spread via ownKeys+getOwnPropertyDescriptor traps so aggregator factories that copy ctx into subCtx don't lose proxy magic. Fix to the test helper: (a) `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; (b) `asyncHandler` seeded as own enumerable property — survives Object.assign({}, ctx, { helpers }); (c) 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. Fix to public-routes-drift: aggregator walks use prefix `/api/v1` (matches src/app.js's bare-mount on apiRouter at /api/v1), direct-mount walks use `/api/v1` + explicit prefixMap entry. Added `routes/themes.js` and `routes/license.js` to directMounts (themes bare-mounted, license on `/license`). Result: **35 suites, 1036 tests, all passing** (was 1030 passing + 6 failing before this commit). The 6 failures were depth-2 factory errors + 22 PUBLIC_ROUTES stale entries that the test infrastructure was silently swallowing.

### DC-019: backup-manager test flakes ~1/64 — tamper uses fixed-char replacement that can be a no-op
- **status:** done
- **owner:** hermes
- **details:** `__tests__/backup-manager.test.js:184` "rejects tampered data (auth tag mismatch)" tampers the encrypted blob by replacing its first base64 character with `'X'`: `Buffer.from('X' + str.substring(1))`. The first char is the first base64 char of the random 16-byte IV. When the IV's first base64 char is already `'X'` (~1/64 ≈ 1.6% probability per run), the replacement is a no-op — the "tampered" buffer is byte-identical to the original, AES-256-GCM decryption succeeds, and `expect(...).rejects.toThrow()` fails. Observed: 1 failure in ~15 full-suite runs. The production `encryptBackup`/`decryptBackup` code (AES-256-GCM, correct) is NOT at fault — the bug is in the test's tampering technique. Fix: corrupt the authTag bytes directly (XOR a byte so the value is guaranteed to change), reassemble the `iv:authTag:ciphertext` format. This guarantees a GCM integrity failure every time.
- **result:** Fixed. The test now parses the `iv:authTag:ciphertext` format, XORs the first authTag byte with `0xFF` (guaranteed value change — can never be a no-op regardless of the random IV/authTag content), reassembles the blob, then asserts decryption rejects. Verified: **30/30 isolated runs + 8/8 full-suite runs (1036/1036), zero failures.** Production crypto code unchanged (it was correct all along — the bug was purely in the test's tampering technique). Confirmed root cause independently with a Node REPL script: corrupting authTag byte0 always throws `Unsupported state or unable to authenticate data`.

### DC-018: Logger.error() swallows writeErrorLog promise — error.log writes are fire-and-forget (flaky test + lost logs in prod)
- **status:** done
- **owner:** hermes
- **details:** `Logger.error()` in `src/utils/logging.js:256` calls `this._log('error', ...)` but does NOT return the result. `_log('error', ...)` returns the promise from `writeErrorLog(...)` (the async disk write to error.log). Because `error()` drops the return value, every `await logError(...)` / `await log.error(...)` caller is actually awaiting `undefined` — the file write becomes fire-and-forget. Symptoms: (1) `__tests__/logging.test.js` "captures request context when req is passed" fails intermittently in the full suite (passes in isolation) — the test reads error.log before the un-awaited appendFile completes. (2) In production, 6 route handlers (`routes/apps/deploy.js`, `routes/apps/removal.js`, `routes/health.js`, `routes/arr/config.js`, `routes/updates.js`) plus the global `boundAsyncHandler` error catcher all `await logError(...)` expecting the write to flush; error entries can be lost if the process exits/restarts immediately after. Latent since the original "unify logger" commit f71e5c5. Fix: add `return` to `Logger.error()` so the `writeErrorLog` promise propagates to callers. No behavior change for `debug/info/warn` (they never returned a promise and don't write to disk).
- **result:** Fixed — one-line change (`return this._log(...)`). The logging flake is eliminated: **10/10 full-suite runs passed** (was ~1-in-6 failure rate before the fix). Production impact: every `await logError(...)` in route handlers and the global Express error catcher now actually waits for the error.log write to flush to disk, so error entries survive fast process exit/restart. No behavior change for debug/info/warn (they never wrote to disk). ESLint clean.

### DC-033: getLocalVersion() returns 0.0.0 — SelfUpdater uses __dirname but is loaded via ./src/docker/self-updater
- **status:** done (commits 20d280f + 77536f4)
- **owner:** krystie
- **details:** Every DashCaddy host running v1.14.x (≤ v1.14.8) silently reports `version: 0.0.0, commit: null` from `/api/v1/system/version`, and `checkForUpdate()` always thinks we are outdated. Root cause: `server.js` lines 69 + 245 do `require('./src/docker/self-updater')`, so inside the container `__dirname` resolves to `/app/src/docker` which has no `package.json` or `VERSION` next to it. The function's outer `try/catch` swallows the `ENOENT` and returns the `{ version: '0.0.0', commit: null }` fallback. Discovered 2026-07-05 when DNS2 was running v1.14.4 (packaged from a pre-build-pipeline-fix tree that was already missing `src/`) and the dashboard showed 0.0.0 even though `/app/package.json` said 1.14.4. Confirmed by two independent investigations (main agent + z.ai subagent) reaching the same conclusion. Fix: rewrite `getLocalVersion()` to walk a candidate list — `path.join(__dirname, '..', '..', 'package.json')` first (the api root), then `path.join(__dirname, 'package.json')` (legacy root-copy contract). Add `console.error` on total failure instead of swallowing silently. Verified live on DNS2: `curl http://127.0.0.1:3001/api/v1/system/version` now returns `{"name":"DashCaddy","version":"1.14.8","commit":"20d280f"}`.
- **result:** Done in two commits. (1) `20d280f DC-033: fix getLocalVersion __dirname resolution` — patched `src/docker/self-updater.js` `getLocalVersion()`. (2) `77536f4 DC-033: bump VERSION to 20d280f (DC-033 commit SHA)` — kept dashcaddy-api/VERSION in sync. Also restored DNS2 working tree to origin/main (was at v1.14.4 packaged from a stale tree; origin/main was at v1.14.8 with DC-020..032 security fixes intact — would have shipped as a downgrade if committed naively). Created `/etc/dashcaddy/sites/dashcaddy-api` → `/opt/dashcaddy/dashcaddy-api` symlink so future trigger.json `apiSourceDir` paths resolve correctly. Health: alive. /api/v1/system/version returns 1.14.8 (20d280f).

---

## P1 — Code Quality

### DC-034: Regenerate get.dashcaddy.net/release tarball as v1.14.9 with DC-033 baked in
- **status:** done (commit 42376e2)
- **owner:** krystie
- **details:** Live `https://get.dashcaddy.net/release/version.json` advertises v1.14.8 (commit `ba23cdf`) but DC-033 is NOT in that tarball — verified by extracting `dashcaddy/dashcaddy-api/src/docker/self-updater.js` from `dashcaddy-1.14.8.tar.gz` and confirming it still has the broken `__dirname` pattern. Every other DashCaddy host that auto-updates to v1.14.8 will hit the same 0.0.0 dashboard bug DNS2 just had. Fix: (1) bump `package.json` to `1.14.9` + update `dashcaddy-api/VERSION` to the DC-033 commit SHA. (2) populate `[Unreleased]` section in CHANGELOG.md with DC-033 entry. (3) run `bash scripts/publish-release.sh` to rebuild + push the tarball to get.dashcaddy.net. (4) verify the live `version.json` reflects the new version + commit. Effort: ~15 min. Risk: low — release pipeline already proven by build-pipeline-fix.
- **result:** Bumped package.json (1.14.8 → 1.14.9) + root VERSION to 1.14.9. Baked commit `42376e2` into dashcaddy-api/VERSION inside the tarball. Built `dashcaddy-1.14.9.tar.gz` (39MB, sha256 `9de120a6277f4169caa6740a15181a80cef1ba716006e3a5aad6e21b9d6542a3`). Published to `/var/www/get.dashcaddy.net/release/` (latest.tar.gz + versioned tarball + version.json + sha256). Backed up old release to `release.backup-20260706-052919`. Refreshed install.sh. Mirrored to dc-contabo-de → `/var/www/get2.dashcaddy.net/release/` (verified via SSH). Tarball verified to contain the DC-033 fix (extracted + grep'd self-updater.js — comment "Resolve package.json/VERSION relative to the api root, not __dirname" present). Live `get.dashcaddy.net/release/version.json` serves v1.14.9. SHA256 matches between local + served tarball. Local notify to localhost:3001 returned HTTP 403 (expected — DASHCADDY_UPDATE_ENABLED=false, intentional). Auto-update now ships the 0.0.0 fix to every host that updates from v1.14.8 → v1.14.9.

### DC-035: Add regression test for getLocalVersion() — prevent DC-033 class from regressing
- **status:** done
- **owner:** krystie
- **details:** DC-033 fixed the bug but nothing in the test suite would have caught it originally. The existing coverage on `self-updater.js` is sparse — no test exercises `getLocalVersion()` directly. Add `__tests__/self-updater-version.test.js` that: (1) `require('./src/docker/self-updater')` (matching what server.js does, NOT `require('./self-updater')` which resolves from cwd and loads the wrong file — that's a separate footgun, see DC-036). (2) instantiate SelfUpdater with minimal config. (3) call `getLocalVersion()`. (4) assert `version` is NOT `'0.0.0'` and is in semver shape (`/^\d+\.\d+\.\d+/`). (5) assert `commit` matches `/^[0-9a-f]{7,40}$/`. Optionally: parameterize to also exercise `require('./self-updater')` from `/app` cwd to verify the legacy root-copy contract still works. Effort: ~20 min. Pattern: matches DC-017's depth-2-routes-smoke.test.js (loads every module via the real path).
- **impact:** Catches the exact class of bug DC-033 fixed, plus any future refactor that re-introduces the __dirname antipattern.
- **result:** Added `dashcaddy-api/__tests__/self-updater-version.test.js` (6 tests, all passing). Validates: (1) module loads + exports SelfUpdater class; (2) getLocalVersion returns an object with version+commit (not null); (3) version is NOT `'0.0.0'` (the DC-033 bug sentinel); (4) version matches `/^\d+\.\d+\.\d+/` semver; (5) commit is a 7-40 char hex SHA; (6) works regardless of how the module is required. **Verified the test actually catches the bug** by temporarily reverting self-updater.js to the pre-DC-033 code (`git show 20d280f^`) — 4 of 6 tests failed with the expected `expect.toBe('0.0.0')` and `not.toBeNull` assertion errors. After restoring the fix, full suite passes: **40 suites, 1081 tests** (was 39/1075, +6 new).

### DC-036: Delete dead `dashcaddy-api/self-updater.js` (root copy) — 0 runtime callers
- **status:** done
- **owner:** krystie
- **details:** After DC-005 refactor (commit 283121e), there are TWO SelfUpdater implementations on disk: `/opt/dashcaddy/dashcaddy-api/self-updater.js` (md5 `79d566cc...`) and `/opt/dashcaddy/dashcaddy-api/src/docker/self-updater.js` (md5 `b3b61557...`). Both have drifted. **Zero runtime callers of the root copy** — verified by `grep -rn "require.*self-updater" dashcaddy-api/ --include="*.js"` which shows only `./src/docker/self-updater` (in server.js + src/app.js). The root copy is dead code from a prior refactor and a footgun for future contributors who edit the wrong file. Subagent flagged this independently. Fix: `git rm dashcaddy-api/self-updater.js` + verify `npx jest --passWithNoTests` still passes. Risk: very low. If a test does import it, the test itself is wrong and should be deleted or pointed at `./src/docker/self-updater`.
- **impact:** Removes the wrong-file-edit footgun. Makes DC-035's test cleaner (only one SelfUpdater implementation to test).
- **result:** Verified zero callers (grep + 38 test files scanned — no references to `./self-updater`). Discovered the file was actually gitignored, never committed — so `git rm` was unnecessary; plain `rm` did it. Tests: 1075/1075 still passing post-delete. Also synced `dashcaddy-api/VERSION` to `42376e2` (the DC-033 + release-bump commit) so the rebuilt container reports the right SHA. Live: `curl http://127.0.0.1:3001/api/v1/system/version` returns `{"name":"DashCaddy","version":"1.14.9","commit":"42376e2"}`.

### DC-037: Move `/etc/dashcaddy/sites/dashcaddy-api` symlink creation into the install script
- **status:** in-progress
- **owner:** krystie
- **details:** DNS2 has the symlink manually created during this session (2026-07-05), but every other fresh DashCaddy install will hit the same `cp: cannot create directory '/etc/dashcaddy/sites/dashcaddy-api/routes': No such file or directory` failure when the first auto-update lands, because `dashcaddy-update.sh` defaults `apiSourceDir` to `${CADDY_BASE}/sites/dashcaddy-api` (= `/etc/dashcaddy/sites/dashcaddy-api`) while the actual install lives at `/opt/dashcaddy/dashcaddy-api`. Fix: add `mkdir -p /etc/dashcaddy/sites && ln -sfn /opt/dashcaddy/dashcaddy-api /etc/dashcaddy/sites/dashcaddy-api` to the install script (whichever of `dashcaddy-installer/install.sh` or `scripts/dashcaddy-install.sh` is canonical — verify which exists on a clean install). Make it idempotent (`ln -sfn`, not `ln -s`, so re-runs don't fail). Effort: ~10 min. Risk: very low.
- **impact:** Prevents every future DashCaddy host from hitting the v1.14.4-class update failure on first auto-update.

---

## P2 — Polish & DX

### DC-038: Backup trigger.json + result.json in dashcaddy-update.sh — enable one-command rollback
- **status:** done
- **owner:** hermes
- **details:** During the DC-033 fix, recovering from the failed v1.14.4 update required manually mv'ing `trigger.json.processing` back to `trigger.json`, manually running `start.sh`, etc. — because the backup mechanism in `dashcaddy-update.sh` (lines 318-327) only backs up code + data, not the trigger/result state. Fix: in the `backup_data_dir` function (or new `backup_update_state` function), also copy `${UPDATES_DIR}/trigger.json` and `${UPDATES_DIR}/result.json` into the versioned backup directory so rollback tooling can restore them. Effort: ~15 min.
- **impact:** Faster incident recovery. Currently takes 5-10 manual steps to roll back a failed update; would take 1.
- **result:** Added \`backup_update_state()\` function in \`dashcaddy-update.sh\` (idempotent, tolerates absent files + chattr +i, cleans up empty subdir). Wired into \`main()\` immediately after \`backup_data_dir()\`. Backs up \`trigger.json.processing\` + \`result.json\` into a \`update-state/\` subdir of the versioned backup. Deliberately does NOT auto-restore on rollback — the rollback handler reads a fresh trigger.json written by the operator/container; restoring the previous attempt's trigger would clobber the active rollback request. New regression test \`dashcaddy-api/scripts/test-dashcaddy-update-backup.sh\` (14 assertions across 5 groups: both-files-present, partial-present, no-files-present, idempotency, main() flow ordering) — all pass. Tests: 1214/1214. Lint: 150 warnings, all pre-existing in untouched files, zero new warnings introduced.

### DC-039: Audit repo for other `__dirname + sibling-file` patterns — DC-033 class of bug
- **status:** done
- **owner:** hermes
- **details:** DC-033 was caused by `path.join(__dirname, 'package.json')` in a module loaded from a subdirectory. There may be other instances of the same pattern elsewhere in `src/`. Quick grep: `grep -rn "path.join(__dirname" dashcaddy-api/src/ --include="*.js"` and review each hit. Any that join `'package.json'`, `'VERSION'`, `'.env'`, `'openapi.yaml'`, `'Dockerfile'`, or `'.license-secret'` is suspect (these all live at the api root, not in subdirectories). For each suspect match, either: (a) verify the file does exist at the expected `__dirname` location, or (b) fix it to use the api-root path. Effort: ~30 min. Risk: low. Just an audit + targeted fixes.
- **impact:** Catches latent bugs before users do. The fact that DC-033 shipped undiscovered through multiple releases suggests this antipattern might exist elsewhere.
- **result:** Found **and fixed** the antipattern across 10 modules in `src/`. 13 distinct `path.join(__dirname, 'foo.json')` defaults (plus the `__dirname` based `LOG_DIR`/`ERROR_LOG_FILE`) all wrote runtime state into the source tree, surviving in dev but landing in the image layer in production. Centralised resolution in `platformPaths.dataDir` (derived from `SERVICES_FILE` env when set, else `path.dirname(servicesFile)`); the 10 modules now route their `*-config.json` / `*-history.json` / `.port-locks` / `audit-log.json` / `error.log` / `.license-secret` / `.license-counter` defaults through it, preserving per-file env-var overrides. `crypto-utils.js` and `credential-manager.js` already had a multi-candidate resolver; collapsed them to a single `platformPaths.dataDir` lookup. The `host-registry` / `event-store` / `event-workers` `dataDir || path.join(__dirname, '../../data')` pattern simplified — the legacy fallback is unreachable now that `services.json` lives at `dataDir`. Also fixed a **real production bug found mid-audit**: `audit-logger.js` defaulted `AUDIT_LOG_FILE` to `/app/src/security/audit-log.json` and `logging.js` defaulted `LOG_DIR` to `__dirname` (i.e. `/app/src/utils/`), so every error-log/audit-log write was landing in the image layer — a fresh container recreate would have wiped the entire audit log. Now both flow through `dataDir` which the start.sh bind mount already points at `/app/data`. Drive-by: removed unused `readline` import in `event-workers.js`. Also fixed a **test gap** in `__tests__/public-routes-drift.test.js`: `routes/security.js` was missing from the direct-mounts list, so the `/api/v1/security/events/ingest` and `/api/v1/security/events/batch` PUBLIC_ROUTES entries (added by DC-044) were flagged as stale. Added it with `/security` prefix mapping. **Pre-existing files on the running container (`audit-log.json` 319KB, `container-stats*.json` 186MB, `workflow-history.json` 269KB, `audit-log.json` etc.) are still in the image layer** — those are lost on next recreate unless a one-time migration step runs; out of scope for this fix but flagged for a follow-up. **Tests: 1214/1214 pass, +0 failures. ESLint: 146 warnings + 4 errors — identical to baseline (no new warnings/errors introduced).** Docker container does NOT need rebuilding: the affected code paths are evaluated at boot, and `dashcaddy-api/data/` is the existing bind mount — the new defaults resolve to the same path the container already uses via env vars (`CREDENTIALS_FILE=/app/data/credentials.json`, `ENCRYPTION_KEY_FILE=/app/data/.encryption-key`, etc.), and the env vars take precedence. Self-updater picks it up on the next release bump.

### DC-040: Investigate whether dashcaddy-post-deploy-patches.sh is still needed at all
- **status:** done
- **owner:** hermes
- **details:** The script applies 23+ `require()` path fixes on every update (audit from `BUILD-PIPELINE-FIX.md` shows it was created to paper over `dashcaddy-api/src/` being missing from tarballs). After the build-pipeline-fix (which now ships `src/` in every tarball), most of those patches should be no-ops. If any are still applying real changes, that means the source tree has a latent bug that DC-005-era refactors missed. Run `bash scripts/dashcaddy-post-deploy-patches.sh` against a fresh checkout of origin/main (or extract the v1.14.8 tarball to a clean dir) and count how many patches actually change anything vs are no-ops. If most are no-ops, the script can either be deleted entirely (cleanest) or kept as a defensive backstop with a comment explaining its purpose has shifted to "verify src/ shipped correctly." Effort: ~45 min. Risk: medium — safer to keep as backstop with reduced scope.
- **impact:** Clarity. The current state — "script applies 23 fixes every update but only 3-4 actually do anything" — is opaque and brittle.
- **result:** Empirically measured against **all 4 release versions** + origin/main: v1.14.4 (broken — no src/ in tarball), v1.14.8, v1.14.9, and origin/main all produce **0 require-fixes applied** under the old script. Every patch is a no-op against every current release. Decision: **KEEP the script but repurpose it as a VERIFIER, not a patcher.** The script now performs 5 explicit checks (server.js requires correct, license-manager.js path correct, src/ directory present + non-empty + contains app.js, license-keygen.js at API root) + an informational scan of all src/ require paths. **Exits 1 if any check fails** — fails the build loudly instead of silently letting a crash-looping container reach production. Behaviour change: the OLD script would silently no-op on v1.14.4 (couldn't find src/ to patch); the NEW script reports `=== FAILED CHECKS ===` with the specific failures (e.g. `src/: directory missing — v1.14.4-class bug`). Verified against v1.14.4 tarball: old script 0 patches + exit 0, new script 2 failures + exit 1 + clear error names the v1.14.4-class bug. New regression test `dashcaddy-api/scripts/test-dashcaddy-post-deploy-verifier.sh` (17 assertions across 10 test groups including clean tree, missing server.js, broken server.js requires, missing src/, missing license-keygen.js, broken license-manager path, empty src/, missing src/app.js, absolute path resolution, non-existent API_DIR) — all pass. Tests: 1214/1214 Jest + 31 shell assertions. Lint: 150 warnings, all pre-existing in untouched files.

### DC-041: Add integration test for the auto-update pipeline (trigger.json → bash → docker rebuild → health check → result.json)
- **status:** in-progress
- **owner:** hermes
- **details:** The host-side updater has zero integration coverage. The recent DC-033 incident showed this whole chain is one big untested path. Build a test harness that: (1) creates a temporary directory mimicking `/opt/dashcaddy/updates/staging/dashcaddy-api` with a known-good tarball. (2) writes a `trigger.json` to a test `UPDATES_DIR`. (3) runs `bash /opt/dashcaddy/scripts/dashcaddy-update.sh` with paths overridden via env vars. (4) asserts `result.json` has `success: true` and the version matches. (5) cleans up. Effort: ~2 hours. Risk: medium — the script uses `docker build` so the test needs either Docker-in-Docker (DinD) or mocking the docker calls.
- **impact:** Closes the biggest untested surface in DashCaddy. Would have caught the v1.14.4 packaging bug immediately on the next release.

### DC-042: Replace null stubs in src/app.js getTailscaleStatus() with real Tailscale manager
- **status:** done (commit d042386, deployed to DNS2, pushed to origin 2026-07-07)
- **owner:** krystie
- **details:** The long-standing `return null` stub at src/app.js:189 (plus 8 null fn stubs on `ctx.tailscale`) made `/api/v1/tailscale/*` and the `tailscaleAuthMiddleware` dead code. New module `src/managers/tailscale-manager.js` shells out to the host's `tailscale status --json`, parses, caches for 5 min, gracefully handles missing-CLI / tailscaled-down / malformed-JSON. Re-exports `isTailscaleIP` from network-detector.js. Wired into `src/context/index.js`. start.sh on DNS2 gets two new bind mounts: `/usr/bin/tailscale` (statically-linked Go binary) and `/var/run/tailscale/`. Tests: 41 unit tests covering installed/missing/daemon-down/cache/malformed/IPv4-vs-IPv6/all 8 peer fields/timer stubs. Suite went 1097 → 1138 tests passing.
- **impact:** Dashboard's Tailscale card now shows real device list (8/9 online). `tailscaleAuthMiddleware`'s allowedTailnet check no longer dead code. Foundation for DC-043 share-invite flow.

### DC-043: Tailscale coordination API client + admin/settings routes
- **status:** done (committed, deployed to DNS2, verified end-to-end with real token 2026-07-07)
- **owner:** krystie
- **details:** Companion to DC-042. New module `src/managers/tailscale-coord.js` is the *write-side* REST client for `https://api.tailscale.com/api/v2/`. Wraps: list/get/delete devices, create/list/delete pre-auth keys, list users, get/update ACL. New `ctx.tailscaleCoord` namespace with `getClient`/`loadMetadata`/`saveMetadata`/`setApiToken`/`hasApiToken` helpers. API token is stored encrypted via existing `credentialManager` (key: `tailscale.coord.apiToken`); metadata in plaintext `tailscale-config.json`. New routes in `routes/tailscale-admin.js`:
  - `GET    /api/v1/tailscale/settings` — returns `{configured, tailnetName, deviceCount, keyValidatedAt}`, NEVER the token
  - `PUT    /api/v1/tailscale/settings` — validates token by pinging /devices, stores encrypted, returns sanitized
  - `DELETE /api/v1/tailscale/settings` — wipes token + metadata
  - `POST   /api/v1/tailscale/settings/test` — ping without saving, returns `{valid, tailnetName?, error?}`
  - `GET    /api/v1/tailscale/admin/devices` — full device list via coord API
  - `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
- 74 unit + route tests (45 client + 29 route integration). Suite: 1214/1214 passing.
- **deployed to DNS2, verified:** `docker exec dashcaddy-api node ...` against the real token returned `ping: {domain: "tail3e209.ts.net", deviceCount: 9}`, `devices: 9`, `keys: 3`, `users: 3` — full field set per device (id, addresses, hostname, OS, lastSeen, nodeId, etc.).
- **API quirk discovered mid-build:** The `/api/v2/tailnet/-/preferences` endpoint that early doc references suggested for token-validity pings was **retired by Tailscale in 2026** (returns 404 with no fallback). ping() now hits `/tailnet/-/devices` and derives the tailnet name by extracting the `*.ts.net` suffix from the first device's `name` field. Also discovered `core.worktree` confusion mid-session — git thought `/opt/dashcaddy`'s repo lived at `/root/dashcaddy`, which caused the first commit to appear "lost" until I recovered via `git reset --hard <sha>` from the reflog.
- **intentionally NOT built:** token auto-rotation / auto-renewal. Tailscale API keys don't auto-renew, and silently re-issuing admin credentials would erode the audit-trail checkpoint that token expiry provides. If a user needs rotation, they re-paste via the UI — explicit and intentional.
- **impact:** Foundation for DC-044 (Plex/whatever share-invite flow). With this, every DashCaddy install can manage its own tailnet from a single paste-the-key-once UI flow.

---

## Backlog note (2026-07-05)

Tickets DC-033 through DC-041 were added after the DNS2 v1.14.4 / v1.14.8 / 0.0.0 incident. They are grounded in real evidence from that session — see DC-033's details for the full chain of reasoning (cross-checked by main agent + z.ai subagent).

### DC-044: Fix WorkflowEngine healthCheckService — servicesStateManager.getState bug (silent every-5min error spam)
- **status:** in-progress
- **owner:** hermes
- **details:** `src/recipes/bundled-workflows.js:310` calls `servicesStateManager.getState()` which doesn't exist (StateManager exposes `read()`, not `getState()`). Combined with a missing `await`, the call returned a Promise (truthy), short-circuited via `|| []` to an empty array, then `for (const service of services)` silently iterated over zero services. Net effect: every `health-check-on-interval` workflow ran every 5 min, reported `Action health-check failed: servicesStateManager.getState is not a function`, sent a "Health check failed for {{serviceId}}" notification (with the unresolved template!), and produced `checked: 0, healthy: 0` results. Visible on BOTH DNS2 (production, 4 days) and test server dc-contabo-de (9 days). Fix: call `await servicesStateManager.read().catch(() => [])` — proper async + corruption-tolerant.
- **impact:** Workflow health checks now actually check container health, instead of silently reporting 0/0 every cycle. Stops the "Health check failed" notification spam.
- **result:** Fixed in src/recipes/bundled-workflows.js. New regression test `__tests__/bundled-workflows-health-check.test.js` — 5 cases (uses .read() not .getState(), correct counts, graceful degrade on read() throw, no servicesStateManager on ctx, single-service path). Full suite: 1219/1219 pass (+5 new).

### DC-046: Pluggable AuthProvider interface — refactor TOTP into one of N providers
- **status:** todo
- **owner:** unclaimed
- **details:** Today DashCaddy has only one login method (TOTP). For a public-release product we need at least a second (email magic link), and the TOTP-only design doesn't scale — every new user needs a TOTP secret provisioned manually, no self-service recovery, no per-user audit trail. Refactor: define a `AuthProvider` interface in `src/auth/providers/` with methods `{ name, enabled, loginMethods, initiate(req) -> {redirect, challenge?}, verify(req) -> {user} }`. Move the existing TOTP code into `src/auth/providers/totp.js` as one implementation of that interface. `createApp` composes all enabled providers and exposes them via `/api/v1/auth/login` and `/api/v1/auth/login/:method` routes. Login page lists all enabled providers with their own button. Zero behavior change for existing TOTP users — the route shape becomes `/api/v1/auth/login/totp` instead of `/api/v1/auth/login`, but the existing UI is rewritten to match. Effort: ~1 hr. Risk: medium (touches the auth path that is the most security-sensitive area of the codebase).
- **impact:** Unlocks every other auth provider (DC-047 email magic link, DC-048+ OIDC, SAML, etc.) without further refactors of the auth path.

### DC-047: EmailMagicLinkProvider — email-only login via nodemailer
- **status:** todo
- **owner:** unclaimed
- **details:** Second AuthProvider implementation, sitting alongside TOTP. **Email IS the identity — no separate username field at any point.** Flow: user enters email at `/login`, server generates a single-use token (32 random bytes, base64url), stores it in `data/email-tokens.json` with 15-min TTL, sends an email via the existing nodemailer connection in `src/managers/notification-manager.js:290` (reuse the same SMTP config — `providers.email.host/port/username/password/from`). Email body contains a link like `https://dashcaddy.example.com/auth/verify?token=abc123`. Click → server validates token (exists, not expired, not already used) → marks used → creates session cookie → redirect to dashboard. On subsequent visits, session cookie is the credential. Rate-limit the request-link endpoint to 5 per email per hour to prevent email-bombing. Tokens stored as SHA-256 hashes in the JSON store so a read-only compromise can't be used to forge links. Effort: ~3 hrs. Risk: medium (depends on SMTP creds being configured; if not, fall back to console-logging the link in dev mode).
- **impact:** Public product readiness. Zero-password login. No username/email split — one field, one identifier. Reuses existing nodemailer config — no new dependency, no new credential surface. Works with any SMTP server Sami already uses (he mentioned using the SMTP server his website runs).
- **prerequisite:** DC-046 (the interface to implement against).

### DC-048: Multi-user bootstrap + admin invites
- **status:** todo
- **owner:** unclaimed
- **details:** The user model shifts from "one implicit operator" to "many users with explicit roles". Bootstrap rule: the FIRST email to ever successfully log in via email magic link becomes the admin. Subsequent emails are denied with a `not authorized` error UNLESS the email appears in `data/authorized-users.json`. Admin UI: a `/users` page that lists authorized users, lets admin add emails (manual entry) or generate single-use invite links (which work like magic links but pre-add the email to the allowlist on first use). Audit log gets `userEmail` attribution on every entry. License model is unchanged (still per-host) but note in the ticket that this may need revisiting. Effort: ~2 hrs. Risk: low (mostly UI + JSON-store CRUD).
- **impact:** First real multi-user DashCaddy. Per-user audit attribution. Self-service invites. Foundation for any future "team" features.
- **prerequisite:** DC-047 (needs email auth working first).

### DC-049: Update login UI to show multiple providers
- **status:** todo
- **owner:** unclaimed
- **details:** Currently the login page is TOTP-only. Once DC-046/047/048 ship, login needs to render ALL enabled providers as a list of buttons, each routing to its provider-specific initiate flow (`/api/v1/auth/login/totp`, `/api/v1/auth/login/email`). Frontend work — `status/js/core/login.js` and the login modal markup. Add a small "Choose how to sign in" header. Effort: ~1 hr. Risk: low (pure UI, no backend changes).
- **impact:** Makes the pluggable auth provider pattern visible to users. Without this, providers other than TOTP are unreachable.
- **prerequisite:** DC-046 + DC-047 (needs at least two providers to be meaningful).

### Backlog note (2026-07-14)

Tickets DC-046 through DC-049 implement pluggable auth + email magic link. Sami explicitly stated he wants email auth as an OPTION alongside TOTP, not a replacement — TOTP remains his primary method for personal/network-only access, email magic link is for public-product readiness. Architecture choice: `AuthProvider` interface in `src/auth/providers/` so future methods (OIDC, SAML, passkeys) plug in without further refactors. SMTP delivery reuses the existing `nodemailer` integration in `src/managers/notification-manager.js:290` — no new dependency. Sami plans to use the SMTP server his website runs (sami-ahmed.net) so the host field will be configurable. Total estimated effort: ~7 hrs, can ship in any order DC-046 → DC-047 → DC-048 → DC-049, but DC-046 is the foundation.

### DC-045: Fix WorkflowEngine init — `new (require(...))()` precedence bug on ES6 classes
- **status:** done
- **owner:** hermes
- **details:** server.js:93 (v1.13.4) instantiated `new (require('./src/managers/notification-manager'))({...})`. V8 parses this as `(new (require('./x')))(opts)` — which invokes the module's exported class AS A FUNCTION (without `new`), triggering `Class constructor NotificationManager cannot be invoked without 'new'` at server startup. Result: workflow engine never initializes on the running test server (dc-contabo-de). Combined with DC-044 (the .getState bug), the workflow feature has been broken since at least v1.13.4 and visible on both DNS2 + test server.
- **impact:** Workflow engine now starts cleanly. Health-check-on-interval workflow now actually runs against real services instead of silently 0/0.
- **result:** Hoisted `const NotificationManager = require(...)` and used `new NotificationManager({...})` in the server.js init block. Verified live on dc-contabo-de: workflow engine now logs `Workflow engine initialized` on startup; 90s of post-restart logs show zero `getState is not a function` errors, zero `WorkflowEngine Action health-check failed` spam, zero error-priority entries. Health check: 200 OK with uptime reporting.

1. **Always `git pull` before starting work.**
2. **Claim a task by editing BACKLOG.md:** set `status: in-progress` and `owner: hermes` or `owner: krystie`.
3. **Commit BACKLOG.md claim first**, then start coding.
4. **Run tests before pushing:** `cd dashcaddy-api && npx jest --passWithNoTests`
5. **Push to `main`** — use `http://sami7777:<token>@100.98.123.59:3000/sami7777/dashcaddy.git`
6. **Update BACKLOG.md** when done: set `status: done`, add brief result under the task.
7. **Never work on a task another bot has claimed** (status: in-progress).
8. **Quality bar:** this is a public-release product. No hacks, no env-var workarounds, no per-machine patches. Fixes go in the shared codebase.
9. **VERSION bump:** when a batch of tasks is done, bump patch version in package.json + VERSION file, update CHANGELOG, tag.
