Files
dashcaddy/BACKLOG.md
T
Hermes d8459a4a87 DC-085 link-first invite — Discord-style share it however you want
Flip POST /api/v1/auth/admin/invites default to no email; always return
the link. Operators copy + share via iMessage/WhatsApp/SMS/Signal/Telegram/
Discord/paste-in-email. Email becomes an opt-in checkbox (was the default).
Add shareText field with pre-formatted message for one-tap paste. Stop
logging raw invite URLs to error.log when SMTP is unconfigured (was just
a dev fallback — link is now in the response). Frontend flips the
checkbox default to unchecked and renders shareText + native share sheet
button (navigator.share) alongside the raw copy-link button. 9 new tests
covering default-no-send, link-always-returned, shareText-shape, opt-in
SMTP send, failed-SMTP-no-leak. Full suite: 2474/2474 + 9 new = 2483.
2026-08-20 04:46:12 -07:00

103 KiB

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 missedserver.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"}.
  • status: done
  • 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.
  • result: Added install_api_symlink() to dashcaddy-installer/install.sh, called from main() right after start_caddy at end of Step 7. The function does mkdir -p /opt/dashcaddy && ln -sfn "${API_DIR}" /opt/dashcaddy/dashcaddy-api (idempotent: -sfn replaces stale links and does not fail on re-runs; ${API_DIR} resolves to /etc/dashcaddy/sites/dashcaddy-api per the existing readonly constants at lines 23-26). The mkdir -p /opt/dashcaddy ensures the symlink's parent directory exists on a fresh host before ln -sfn runs. bash -n install.sh returns SYNTAX OK. The auto-updater's DATA_SOURCE_DIR=/opt/dashcaddy/dashcaddy-api/data and other /opt/dashcaddy/... defaults now resolve cleanly through the symlink on fresh installs. Existing DNS2 host is unaffected (the symlink already exists there from the manual session 2026-07-05; ln -sfn would replace it with the same target if re-run).

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: done (commit 0b85caa, 5 scenarios / 37 assertions all green)
  • 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.
  • result: dashcaddy-api/scripts/test-dashcaddy-update-integration.sh (552 lines) commits and exits 0. Strategy: sandbox at /tmp/dashcaddy-test-XXXXXX/opt/dashcaddy/ with /opt/dashcaddy path-rewritten via sed, mocked docker binary prepended to PATH, real dashcaddy-post-deploy-patches.sh verifier copied in, and a Python one-shot HTTP responder on port 33001 driving the health check (33001 chosen to avoid clashing with the live DashCaddy API on 3001). 5 scenarios: (1) happy-path update v1.14.8→v1.14.9 with mocked docker build/rm/run, backups, result.json; (2) v1.14.4-class broken tarball (no src/) — asserts the verifier IS invoked and DOES detect the bug ("Build should be ABORTED" in log); current dashcaddy-update.sh warns-and-continues on verifier failure, so this scenario asserts that observed behavior with a TODO note about closing that gap in a follow-up; (3) rollback to a pre-populated backup; (4) no trigger.json → no-op exit 0; (5) prerelease channel rejection when ALLOW_PRERELEASE is not set.
  • 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: done
  • 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: done
  • owner: hermes
  • 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.
  • result: Shipped. 6 new modules under src/auth/providers/ (~1100 LOC): base.js (AuthProvider contract), totp.js (TOTP impl), email.js + email-tokens-store.js + email-sender.js (DC-047 email impl, included here because the registry requires both), index.js (createAuthProviderRegistry). New routes/auth/login.js (109 LOC) mounts under /auth. Existing routes/auth/index.js wires the registry + mount. src/utilities/middleware.js + src/security/csrf-protection.js PUBLIC_ROUTES + CSRF entries updated to /api/v1/auth/login/:provider/{initiate,verify} and /api/v1/auth/disable/:provider (parameterized, future-proof for OIDC/SAML). __tests__/auth-provider-registry.test.js (9 new tests) covers registry composition, no-secrets-leak guarantee, enabled-flag respect, dev-console fallback for the email provider. __tests__/public-routes-drift.test.js fixed for Express 4.22.x compat (the previous regex extraction broke on the new ^\/path\/?(?=\/|$) source format). Tests: 1241/1241 passing across 46 suites (was 1232; +9 new).

DC-047: EmailMagicLinkProvider — email-only login via nodemailer

  • status: done
  • owner: hermes
  • 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).
  • result: Shipped as part of DC-046 commit. src/auth/providers/email.js (388 LOC): registers magic-link (initiate) + verify-token (verify) methods, generates 32-byte base64url tokens, stores SHA-256 hashes via email-tokens-store.js. email-tokens-store.js (260 LOC): atomic lockfile-based mutation, automatic cleanup of expired tokens, audit log on every issue/use. email-sender.js (67 LOC): wraps nodemailer if providers.email config is set, else falls back to log.info('auth', 'email magic link issued', ...) so dev installs work without SMTP config. Verified with stub deps: initiate() writes a token + logs deliveredVia: 'dev-console' + returns masked email; verify('verify-token', { token: 'garbage' }) throws AuthenticationError (route handler converts to 401). Real SMTP wiring takes effect as soon as providers.email.host/port/username/password are set in config.json.

DC-048: Multi-user bootstrap + admin invites

  • status: done
  • owner: hermes
  • 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).
  • result: Shipped as opt-in. Email auth must be explicitly enabled via siteConfig.authProviders.email.enabled = true; single-user TOTP-only installs see zero behavior change. New modules: src/security/user-store.js (users + allowlist + bootstrap sentinel, atomic writes, last-admin protection, 380 LOC), src/security/invite-store.js (single-use tokens, SHA-256 hashed on disk, TTL, 230 LOC). New routes: routes/auth/admin.js (/me, /admin/users GET/POST/PATCH/DELETE, /admin/allowlist, /admin/invites GET/POST/DELETE, public /invites/:token peek + /invites/:token/accept redeem, 360 LOC). EmailMagicLinkProvider verify() calls userStore.isEmailAuthorized() then userStore.login() then tags req.user for audit attribution; TOTP verify() bootstraps a system@totp.local admin record on first login so the current operator shows up in /admin/users without a re-login. Audit logger middleware reads req.user and adds userId/userEmail/userRole/viaProvider to log details. New admin UI: status/js/admin.js (modal overlay, users list with role-edit + delete, invite form with copy-link button, outstanding-invites list with revoke). Wired into core/init.js so the "Admin" trigger button appears in the top bar only when /me returns isAdmin: true. 35 new tests across 3 files. Full suite: 1298/1298. Update PUBLIC_ROUTES + CSRF allowlists for the new invite redemption paths (same exemption rationale as login verify).

Backlog note (2026-07-20, hermes)

DC-046 + DC-047 landed together in one commit because the registry requires both implementations to be loaded at startup — splitting them would mean a half-broken registry at the intermediate commit. The commit message documents both IDs.

DNS2 deploy: code change + scripts/publish-release.sh + docker build + bash start.sh + live verify. After this lands, /api/v1/auth/login/methods returns both totp and email providers for any host with email-magic-link enabled. Hosts without SMTP configured fall back to the dev-console path so end-to-end testing works before production SMTP is provisioned.

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.

  • 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: done
  • owner: hermes
  • 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).
  • result: Shipped. New module status/js/auth-gate.js (~290 LOC) owns the ?auth=required flow: queries GET /api/v1/auth/login/methods, renders one of three UIs — provider selector (2+ enabled), TOTP overlay + email fallback link (only TOTP enabled, email available), or pure legacy TOTP (truly single-provider). email provider renders inline: text input + "Send sign-in link" button that POSTs to /api/v1/auth/login/email/initiate; on success shows the masked recipient + deliveredVia ('dev-console' vs 'inbox'). Coordination with totp-auth.js: auth-gate.js sets window.__dc_049_handled = true at IIFE entry so the legacy TOTP module skips its own UI when auth-gate is in charge, eliminating flicker on multi-provider installs. Bundle order in build.js: auth-gate BEFORE totp-auth (flag must be set first). Verified live: https://status.sami/dist/core.js contains all 4 expected markers (_showAuthGate, provider-btn, auth-gate-email-input, __dc_049_handled). SW cache hash dashcaddy-shell-c550d0b371 (was dashcaddy-shell-310b97d25a before this work). User instruction: hard-refresh status.sami to pick up the new bundle.

DC-050: Harden platform-paths.dataDir — structural guard against image-layer data loss

  • status: done
  • owner: hermes
  • details: DC-039 audited and fixed every module that defaulted path.join(__dirname, 'foo.json') — the audit-logger, license-keygen, credential-manager, port-lock-manager, resource-monitor, log-digest, update-manager, and crypto-utils all now route through platformPaths.dataDir. Verified live on DNS2: the live audit log at /app/data/audit-log.json is 315 KB and being actively written; the vestigial /app/src/security/audit-log.json is 2 bytes (Jul 6) and never written to post-fix.
  • What was left undone (now fixed): the structural guard. platformPaths.dataDir resolved via path.dirname(SERVICES_FILE). If SERVICES_FILE env was unset (e.g. operator deletes the -e flag from start.sh), the fallback chain went path.join(CADDY_BASE, 'services.json')/etc/dashcaddy/services.json → dataDir = /etc/dashcaddy. That's the IMAGE LAYER on Docker. Audit-log + license-secret + error.log would silently land there and vanish on every container recreate. Same failure shape as DC-039, but a different code path.
  • Fix (three parts): (1) platform-paths.assertSafe({ mode }) — throws a clear FATAL in production mode if dataDir resolves into any of 11 forbidden zones (/app/src, /app/routes, /app/scripts, /app/utils, /app/managers, /app/security, /etc, /etc/caddy, /etc/dashcaddy, /usr, /usr/local, /var, /var/lib/caddy). Calls a second predicate isMountedCheck(dir) that returns false for non-writable or non-existent dirs (Windows warning, not throw). Bypassed with SKIP_DATA_DIR_GUARD=1. (2) server.js:35 — calls assertSafe before any other startup work. Refuses to boot loudly instead of running with a path that loses data silently. (3) start.sh:13-66 — one-time migration step runs before docker run. Scans 6 known image-layer zombie paths (/opt/dashcaddy/dashcaddy-api/src/{security,utils,managers}/*), copies any non-empty content to ${DATA_DIR}/migrated-*, gates one-shot with a sentinel file .migrated-from-image-layer. Idempotent. Survives set -e per-file failures. Per-file cp -a guarded so a single unreadable zombie can't take the container down. Will recover the 140 KB error.log that the live DNS2 container has in its image layer (timestamp Jul 6 — pre-DC-039 era).
  • result: 19/19 platform-paths tests pass (8 new for assertSafe + 3 new for isMountedCheck). 5/5 start.sh migration tests pass (sentinel-skips, file-copies, idempotent-no-clobber, empty-file-skip, set-e-survives-failure). DNS2 deploys unchanged except for the new migration step running once on next recreate. Suite overall: 1066/1067 (one pre-existing public-routes-drift failure from in-flight Track A code, untouched).

DC-052: License-tier enforcement — Free caps user count at 3, gates share features on Pro

  • status: done
  • owner: hermes
  • details: Per /root/dashcaddy/PRODUCT-SPEC-DECISIONS.md (locked 2026-07-20): Free = up to 3 users, Pro = unlimited. The DC-048 user-store needs a countUsers() helper. The /api/v1/auth/admin/invites POST handler must check if (users.count() >= 3 && !licenseManager.isPro()) throw new ValidationError('upgrade required', 'tier'). Same check on POST /admin/users (pre-authorize). Share-link creation routes (DC-053) gate on licenseManager.isPro(). Free has NO trial path — there is no automatic Pro trial, no time-limited upsell. The user picks Free or Pro deliberately. LIFETIME keys are creator-only: the API rejects any LIFETIME code at verifyCode time in production. The license-keygen.js --lifetime path stays on Sami's dev machine only; it's never wired to Stripe Checkout.
  • impact: First pricing enforcement. Without this, Pro is just a label. With this, every upgrade path has a clear moment to upsell.
  • prerequisite: DC-048 (shipped).
  • result: Audited the implementation already present in commit 273f6b8 (the backlog status was stale). user-store.js exposes atomic countUsers(). Auth admin routes enforce the 3-user Free cap on both POST /admin/users and POST /admin/invites, returning PaymentRequiredError (402) before creation; invite acceptance also enforces the cap. Share creation is Pro-gated in DC-053. LicenseManager.activate() rejects lifetime codes unless ALLOW_LIFETIME_LICENSE=true, preserving creator-only lifetime keys. Existing regression suite license-tier-enforcement.test.js covers the cap, Pro bypass, invite gate, lifetime behavior, and count/delete semantics. Full Jest baseline and post-audit: 52 suites, 1372 tests passed. ESLint reported 180 existing problems (including 4 existing errors); no source files were changed in this audit, so no new lint issues were introduced.
  • status: done
  • owner: hermes
  • result: Shipped as PROD commit (this session). Share-store (src/security/share-store.js) + share-routes (routes/share.js) + 53 tests (24 store + 29 routes, full suite 1372/1372). Public endpoints CSRF-exempt (token IS proof); admin POSTs gated on licenseManager.isPro() → 402 PaymentRequired on Free. Tailscale path mints single-use ephemeral pre-auth key, emails join link, rolls back the share record if tailscaleCoord.createAuthKey() throws so no orphans leak. Email-delivery failure path exposes raw urlPath so admins can manually deliver when SMTP is down. Drift-test parser hardened against quoted-word comments. Public-route drift test registers routes/share.js with a real-shape shareStore stub so the router walker enumerates the share paths. UI side still pending — no "Share" button on service cards yet, modal not built (admin can still exercise via curl).
  • details: Two new feature surfaces behind a Pro license check. (1) Public share linksPOST /api/v1/share creates a signed URL (e.g. https://status.sami/share/<token>) for a specific service + a TTL (1h/24h/7d). The share page renders a read-only preview: service metadata + a subscribe button that hits /api/v1/share/:token/subscribe to register the visitor's email for updates. (2) Tailscale-mediated sharePOST /api/v1/share/tailscale generates a Tailscale pre-auth key (one-shot, single-use, 24h) scoped to a specific device tag, emails the link to the invitee; clicking it joins them to the host's tailnet and proxies them to the service. Both surfaces gated on licenseManager.isPro() (DC-052). UI: a "Share" button on each service card, modal with the two tabs.
  • impact: The killer Pro feature. "Share your services with anyone, they don't even need a Tailscale account" — that's the pitch. Without this, Pro has no upgrade pull.
  • prerequisite: DC-042 + DC-043 (Tailscale manager + coord API shipped); DC-052 (license check); DC-048 (invite flow model).

DC-054: License-keygen CLI improvements + Stripe webhook bridge script

  • status: in-progress
  • owner: hermes
  • details: Existing dashcaddy-api/license-keygen.js already supports durations [30, 90, 180, 365]. Three additions: (1) --tier pro flag (currently --duration 30/90/180/365 — duration alone implies Pro, so the flag is just for CLI clarity). (2) dashcaddy-api/scripts/stripe-license-bridge.js — listens on STRIPE_WEBHOOK_SECRET, validates checkout.session.completed events, looks up the duration by SKU ID, generates a license key, emails it to the customer, returns {delivered: true} to Stripe. (3) dashcaddy-api/license-keygen.js validation path — already exists, no change. Sami generates initial keys via CLI for the launch.
  • impact: Closes the loop between Stripe payment and license-key delivery. Without this, every sale requires manual key generation by Sami.
  • prerequisite: None. Stripe-side can be set up in parallel with DC-052.

DC-055: dashcaddy.net/pricing static page + Stripe Checkout integration

  • status: in-progress
  • owner: hermes
  • details: Static page at /pricing showing the 5-row tier table (Free / 1mo / 3mo / 6mo / 12mo). Stripe Checkout button per paid tier. On success, the page reveals the license key with copy-button + "Here's how to install it" link. Receipt email sent via Stripe's built-in. No account creation in this flow (Q10 decision — optional dashcaddy.net account is post-v1.0).
  • impact: The conversion surface. Without this, the product is real but unsellable.
  • prerequisite: DC-054 (Stripe webhook bridge so licenses auto-issue).
  • result: Hermes sprint 2026-08-02 shipped the public-routes-drift half of DC-055 (commit 86df178, grade A): public-routes-drift.test.js now correctly maps routes/billing.js to /billing prefix in the walker (was previously bare-mount, so /api/v1/billing/checkout was flagged as stale drift) and adds routes/services.js to directMounts (was missing — /api/v1/services + /api/v1/services/status were incorrectly flagged stale). Removed dead /api/v1/billing/webhook PUBLIC_ROUTES entry — webhooks are handled out-of-process by scripts/stripe-license-bridge.js and the merchant webhook secret never enters the API process. The drift test now has 14/14 pass and the dead entry is documented in code so a re-add would fail loudly. Krystie's prior uncommitted work (src/billing/stripe-client.js, routes/billing.js, scripts/stripe-license-bridge.js, public pricing page, license-keygen LICENSE_SECRET_FILE refactor) is now buildable: full Jest suite is 1486/1486 green, zero new ESLint errors. Remaining for the actual pricing UI commit: verify the standalone scripts/stripe-license-bridge.js works against a real Stripe sandbox end-to-end, then add the pricing page to the Caddy file routing.

DC-057: Close checkout-to-license contract drift before public billing launch

  • status: done
  • owner: hermes
  • details: Codex audit found the current Stripe checkout and webhook bridge cannot interoperate: dashcaddy-api/src/billing/stripe-client.js emits metadata: {tier, period, product} while dashcaddy-api/scripts/stripe-license-bridge.js requires metadata.sku, so every paid Checkout completion returns unknown-sku and no license is delivered. The locked product decisions define one-time 30/90/180/365-day licenses at $20/$50/$70/$99, while the current pricing page and billing client present monthly/annual subscriptions. The product spec promises a license key on the success page, while the current page only redirects to Checkout and documents email-only delivery. The bridge comments and implementation also disagree about whether email failures are recorded for retry/idempotency.
  • impact: Current billing can accept payment without issuing a license, which blocks public release and risks paid-customer support incidents.
  • prerequisite: DC-054 and DC-055 working-tree billing artifacts are present but not yet released as a coherent, verified flow.
  • acceptance: Define one canonical paid-product catalog shared by Checkout, webhook fulfillment, tests, and pricing labels; use the locked USD prices and one-time payment/duration semantics; feed the exact Checkout metadata into the bridge in a cross-module contract test; make duplicate/retry events unable to issue two keys; persist a recoverable license before email delivery and provide an explicit, tested SMTP-failure recovery path; reconcile success-page behavior, one-license-per-host wording, and legal/product copy with the actual secure delivery mechanism.
  • result: Codex grade B. 1498/1498 Jest tests pass (62 suites), zero new ESLint warnings introduced. Shipped as one coherent DC-057 commit (no partial worktree artifacts). Single canonical product catalog (src/billing/catalog.js) shared by Checkout client, webhook bridge, pricing page, and catalog-consistency test. Stripe Checkout rewritten for one-time payment keyed by productId (pro-30d/pro-90d/pro-180d/pro-365d) at $20/$50/$70/$99, with metadata.productId as the single contract feeding the bridge — no SKU drift possible. Webhook bridge now requires payment_status === 'paid' before fulfillment (rejects unpaid/no_payment_required/missing with ack 200) and handles the ACH/SEPA delayed-payment flow via checkout.session.async_payment_succeeded. License is persisted to the durable fulfillment-store before email delivery; on SMTP failure, the lookup endpoint serves the persisted code in pending_email state (the documented recovery path) so the customer can save it manually. Layer-1 (event-id-keyed) and layer-2 (session-id-keyed) idempotency prevent duplicate issuance — a second webhook for the same Checkout Session ID reuses the persisted code, never generating a second key. Stripe Checkout return URLs are derived from STRIPE_PUBLIC_ORIGIN env var or STRIPE_ALLOWED_HOSTS allowlist (not raw Host header) — closes the host-header-poisoning + session-ID-leak class of attack. New success page (status/billing/success.html) reveals the license key with a copy button and polls the lookup endpoint every 1.5s. New test files: stripe-license-bridge.test.js (24 tests — signature, parsing, catalog resolution, idempotency, SMTP recovery, async payment events, lookupSession), billing-lookup.test.js (8 tests — HTTP-level route coverage of /api/v1/billing/lookup/:sessionId via real Express server), bridge-lookup-http.test.js (5 tests — bridge's own /lookup/:sessionId HTTP endpoint, uses exported createServer() factory so the SAME dispatcher the production server uses is exercised), pricing-page-catalog.test.js (9 tests — enforces consistency between catalog and the hardcoded pricing page at the per-tier level, plus success-page existence + lookup-endpoint reference), checkout-origin.test.js (6 tests — covers STRIPE_PUBLIC_ORIGIN, STRIPE_ALLOWED_HOSTS, host-header injection rejection, javascript: scheme rejection, http:// in production rejection). All 3 stale test files from the rolled-back DC-055 attempt removed (__tests__/stripe-license-bridge.test.js, __tests__/routes/billing.test.js). Bridge code refactored: handleWebhook decomposed into verifySignature + parseEventBody + checkEventIdempotency + fulfillCheckout + ensureLicensePersisted step functions (under ESLint complexity=20 cap). Production server created via exported createServer() / createRequestHandler() factories guarded by require.main === module so test imports don't leak an HTTP server. Pricing page (status/pricing/index.html) rewritten as 4 hardcoded tier cards with data-product-id attributes; old monthly/annual subscription toggle removed. Success page (status/billing/success.html) new — copy-button reveal, 1.5s polling, TTL-aware messages. To deploy: set STRIPE_PRICE_PRO_30D/90D/180D/365D env vars + STRIPE_PUBLIC_ORIGIN=https://status.sami (or set STRIPE_ALLOWED_HOSTS=status.sami for header-based fallback); configure the Stripe webhook endpoint to point at the bridge's :3010/webhook URL with the bridge's STRIPE_WEBHOOK_SECRET. Deploy the new pricing + success pages to /var/www/dashcaddy-status/. Bridge runs as scripts/stripe-license-bridge.js on port 3010.

DC-056: ToS + Privacy Policy pages — GDPR-aware, no SOC2/HIPAA for v1.0

  • status: done
  • owner: hermes
  • details: Two static pages at /legal/tos and /legal/privacy. ToS covers: license terms (per-host, non-transferable), prohibited use, refund policy (pro-rated refunds within 14 days of initial purchase), termination. Privacy Policy covers: data collected (license key, host metadata, optional email), data NOT collected, third parties (Stripe — payment, Tailscale — coord API calls only when operator configures it), GDPR rights (access, deletion, portability — even though we have no central account system, we'll respond to direct requests within 30 days). No SOC2/HIPAA — that's a v2 conversation.
  • impact: Legal compliance for taking money. Stripe can technically sell without these but payment processors flag accounts without them.
  • prerequisite: None.
  • result: Added responsive Terms and Privacy HTML at status.sami/legal/{terms,privacy}, a tos meta-refresh redirect to terms, dashboard footer links, and a DNS2 deploy script that rsyncs to /var/www/dashcaddy-status/legal/ then validates each URL via curl. Terms apply the launch requirement of pro-rated refunds within 14 days. Single canonical host (status.sami) — the aspirational legal.dashcaddy.net is deferred to a v1.x deploy when DNS+Caddy vhost+LE cert infra is in place.

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.

DC-058: Share UI — admin modal + public preview page (completes DC-053)

  • status: done
  • owner: hermes (graded B by codex-as-judge)
  • details: DC-053 shipped the full share backend (share-store + 8 routes, 53 tests, Pro tier-gate, Tailscale coordination, email delivery). The BACKLOG.md result explicitly says: "UI side still pending — no 'Share' button on service cards yet, modal not built (admin can still exercise via curl)." Two missing UI surfaces: (1) Admin share modal — a "Share" button on each service card (next to the existing options/delete buttons in status/js/core/grid.js:264-281) that opens a modal with two tabs: "Public link" (1h/24h/7d TTL picker → POST /api/v1/share → show returned URL with copy button + revoke list) and "Tailscale invite" (email input → POST /api/v1/share/tailscale → show delivered status + fallback URL on SMTP failure). Modal should also list outstanding shares for the service (GET /api/v1/share) with revoke buttons. (2) Public share preview page at /share/:token — standalone HTML (similar to status/pricing/index.html and status/billing/success.html) that hits GET /api/v1/share/:token/preview, renders service metadata + an "email me when status changes" subscribe form (POST /api/v1/share/:token/subscribe). The URL path is already returned by the issue endpoints as urlPath (e.g. /share/<token>) — the public-preview page just needs to live at that route. Zero Pro gating on the public page (only the admin modal needs Pro check, since issuing shares is Pro-only). Effort: ~2 hr. Risk: low — the API contract is fully tested.
  • impact: Closes the gap between the public sale surface (DC-057 pricing page) and the Pro feature it sells (DC-053 share API). Without this UI, paying customers have no way to actually use the feature they paid for. Manual curl is not a UX.
  • prerequisite: DC-053 (shipped). DC-052 (Pro gate, shipped).
  • result: Shipped codex-graded B. Admin modal (status/js/share-modal.js, 382 LOC, in features.js bundle) opens via the new share button on each service card (added in status/js/core/grid.js, gated on s.id !== internet same as siblings). Two tabs: Public link (1h/24h/7d TTL picker -> POST /api/v1/share) and Tailscale invite (email -> POST /api/v1/share/tailscale). Modal lists outstanding shares (GET /api/v1/share) with revoke buttons. 402 -> Pro upgrade prompt. 400 (no Tailscale) -> setup prompt. Public preview page (status/share/index.html, 253 LOC) extracts the token from /share/ URL path, fetches GET /api/v1/share//preview, renders service metadata + health badge + Open service CTA. For Tailscale shares, the CTA points to the service URL (the share token is the credential -- Caddy forward_auth checks the share store on each request, so no client-side redemption is needed). Subscribe form posts to /api/v1/share//subscribe. Caddy route required: DNS2 needs a rewrite /share/* /share/index.html rule to serve the page for any /share/ URL. Frontend tests: 3 new node --test files (status/tests/share-modal.test.js, share-preview.test.js, core-grid-share-button.test.js) covering IIFE registration, idempotency, DOM contract, callable openShareModal, source syntax check, public preview endpoint contracts, and the regression guard for the original bug codex flagged (redeem-tailscale must NOT be called from the client -- redemption is server-side). Total: 26 frontend tests pass (was 8 + 4 share-modal + 9 share-preview + 5 grid-button). 1498/1498 backend tests still pass; zero new ESLint warnings. Codex also flagged the original redeem-tailscale placeholder as a critical bug (JS fabricating random deviceIds and silently consuming the one-shot share) -- the redesigned page now leaves redemption entirely to the server.
  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.

DC-059: Joi validation library — schema-based body validation middleware

  • status: done
  • owner: hermes
  • details: Backend uses ad-hoc if (!field) throw new ValidationError(...) checks at every route entry point — 49 such checks across the codebase. They drift from the field semantics, allow unknown keys to flow through, and have no way to express structured types (CIDR, enum, port range). Tracked in DC-PRODUCTION-GRADE-BACKLOG.md as P1-1. Fix: npm install joi@^18, add src/utilities/validate.js exporting validateBody(schema) middleware factory + schemas object with reusable schemas. Apply to destructive routes: backups (schedule/restore/config), apps (deploy/restore/revert), assets (upload/logo). Add __tests__/unit/validate.test.js covering each schema's accept/reject/strip-unknown behaviour. Effort: ~2 hr.
  • impact: Closes P0-3 / P0-4 class of bugs at the schema layer instead of per-route. Prevents future routes from accepting arbitrary body fields. New routes copy-paste from schemas.* and get free validation.
  • prerequisite: None.
  • result: Shipped codex-graded B. New module src/utilities/validate.js (170 LOC) with validateBody(schema, opts) middleware + 9 Joi schemas. Every exported schema has direct unit tests (41 tests total) covering middleware semantics (not just schema.validate). Applied to 8 destructive routes: backups (schedule/restore/config), apps (deploy/restore/revert), assets (upload/logo). Key fixes during codex review: (1) IPv6 CIDR regex was permissive (accepted ::::/64) — replaced with Joi's authoritative string().ip({cidr: 'required'}). (2) appRestore empty-body semantics broke under middleware stripUnknown default — replaced Joi.object({}).max(0) with Joi.any().custom() that enforces non-empty rejection even after strip. (3) appDeploy.config now uses .unknown(true) to preserve template-specific fields (sslType, dnsType, plexClaimToken) that the live frontend posts — without this, deployments would silently break. Removed redundant manual appId check in /backups/schedule and unused mime destructure in /assets/favicon. Duplicate legacy /backups/schedule handler (pre-existing) marked LEGACY with TODO note (Express only matches first registration). 1539/1539 Jest tests pass (was 1498, +41 new). ESLint warnings unchanged (416 total, all pre-existing).

DC-060: Console→logger sweep for src/managers/update-manager.js (49 sites)

  • status: done
  • owner: hermes
  • details: Production code uses console.log/warn/error with [UpdateManager] prefixes in 49 places — these go to stdout/stderr directly, bypassing the unified logger (no structured JSON, no error.log file writes, no log-level filtering, no test capture). Tracked in DC-PRODUCTION-GRADE-BACKLOG.md as P1-2. Fix: import log from ../utils/logging, replace every console.log('[UpdateManager] X') with log.info('update', 'X') (dropping the redundant [UpdateManager] tag), every console.warn(...) with log.warn('update', ...), every console.error('...', err.message) with log.error('update', err) (passing the error object so it lands in error.log with stack + context). For mixed-content strings like Stored old image digest: ${oldImageDigest.substring(0, 40)}... extract the variable into the meta payload: log.info('update', 'Stored old image digest', { digestPrefix }). Effort: ~30 min. Risk: very low — pure logging refactor, no behavior change.
  • impact: Update manager events now flow through the same log pipeline as every other module: structured JSON in prod, pretty-printed in dev, error.log rotation for errors, log-level filtering, test capture via stderr spy. Operators get consistent log format and can grep across modules.
  • prerequisite: None.
  • result: Shipped codex-graded A. All 49 console.* sites in src/managers/update-manager.js now route through log.info/log.warn/log.error from src/utils/logging (tag = 'update'). Mixed-content strings extracted into structured meta payloads (containerName, schedule, imageName, error.message, digestPrefix, oldImageIdPrefix, httpStatus, maxAttempts, attempt, durationMs, scheduledTime, etc.) so fields are queryable instead of inlined into the message. Errors now go through log.error(ctx, errObj) so they land in error.log with full stack trace + context, not just stderr. 1539/1539 Jest tests pass (78/78 update-manager tests still pass). ESLint: 14 pre-existing warnings in this file unchanged, zero new warnings introduced (verified with git stash baseline check).

DC-086: Service-status flicker fix — asymmetric hysteresis on the badge

  • status: in-progress
  • owner: hermes
  • details: Dashboard service badges perpetually flip between green and red for "a few seconds at a time, never stable" (Sami's report, 2026-08-20). Root cause: src/monitoring/health-checker.js recordStatus() emits 'status-check' on EVERY probe (every 30s), and src/websocket/dashboard-ws.js forwards every probe as 'status-change' to the browser with no diff. The frontend live-events.js then unconditionally calls setBadge() — which resets the icon + pill text on every event. A single transient 5xx (Caddy reload, container CPU steal, mid-flight TLS handshake, container restart during probe) flips the badge red and the next green probe flips it back. Fix: add asymmetric hysteresis in _computeDisplayedStatus(serviceId, rawStatus) — going DOWN requires 2 consecutive "down" probes (default HEALTH_DOWN_THRESHOLD=2), going UP requires only 1 (default HEALTH_UP_THRESHOLD=1). History + consecutiveFailures still record raw probe results (operators want full fidelity for postmortems); only the dashboard broadcast is filtered. getCurrentStatus() now returns the displayed status so a page reload shows the same badge as the live SSE stream. Both thresholds are env-var configurable so operators can tune. New tests in __tests__/health-checker-hysteresis.test.js cover: first probe emits; second probe same-status does NOT re-emit; one-down-then-up keeps green; two-down flips to red; one-up after down flips back to green; getCurrentStatus returns displayed not raw. Effort: ~30 min. Risk: low — pure behavior filter, no schema breaks, all 63 existing health-checker tests must stay green.
  • impact: Operators stop seeing perpetual red/green flicker on healthy services. Real outages still get flagged (2 consecutive 30s probes = ~60s before badge flips red, which is still faster than a human notices). Background probe history is unchanged so postmortem analysis still works.
  • prerequisite: None.
  • result: pending — ship + codex round
  • status: in-progress
  • owner: hermes
  • details: Today POST /api/v1/auth/admin/invites defaults to sending the invite link via SMTP; if SMTP is not configured it spams the server console with [DC-048-DEV-INVITE-LINK] log lines. Sami wants Discord-style: the link is always returned in the response, and email is an opt-in checkbox. Operators should be free to copy the link and share it via iMessage / SMS / WhatsApp / Telegram / Signal / Discord / paste-in-email — whatever fits. (1) Flip default sendEmail !== false to sendEmail === true in routes/auth/admin.js so omitting the field means "no email, just hand me the link." (2) Stop logging the raw invite URL to error.log when SMTP is unconfigured — that path was only useful when there was no UI way to grab the link; now there is. (3) Add a shareText field to the response: "Join my DashCaddy as <role> — <acceptUrl> — expires in Nh." for one-tap paste into any messenger. (4) Frontend: status/js/admin.js _renderInviteForm flips the "Send email" checkbox default to unchecked, updates _renderIssuedInviteBanner to show both the raw link AND the shareText (with its own copy button + navigator.share() native share-sheet button where available). (5) New tests in __tests__/admin-invites.test.js covering: default sendEmail=false (no SMTP send attempted, no console log); sendEmail: true triggers SMTP send; shareText is present and well-formed; acceptUrl is always returned; expired sendEmail path doesn't leak token to logs. Effort: ~1 hr. Risk: low — pure behavior flip + UI additive change.
  • impact: Closes the friction between "host wants to add a friend" and "host has to configure SMTP first." Mirrors Discord/Slack/Linear invite flows where the link IS the deliverable. No new tier changes, no schema breaks.
  • prerequisite: DC-048 (invite store + admin route), DC-052 (Pro gate stays).
  • result: pending — ship + codex round

DC-084: Remove redundant active Caddy health check from arch.sami site — eliminate 6 syslog spam lines/min

  • status: done
  • owner: hermes
  • details: /etc/caddy/sites/arch.sami had an active Caddy health check (health_uri /api/stats health_interval 10s) probing 100.120.159.34:5000 every 10 seconds. The upstream Arch Linux server 100.120.159.34 has been permanently unreachable (100% packet loss on ping, ports 5000 + 8080 both time out). Result: 6 level:info HTTP request failed journal lines per minute, 360/hour, 8640/day — pure noise, no dashboard value, no incident resolution. The src/monitoring/caddy-upstream-watcher.js (the same module whose source comments explicitly call out this exact spam as "the noisy spam the dashboard currently sees for 100.120.159.34:5000") ALREADY provides equivalent monitoring: 60s probe cadence (6x less frequent), 5-minute confirmation window before opening incidents, mute toggle, deduped snapshot, incident integration with the health-checker. The active Caddy check is redundant. Fix: edit /etc/caddy/sites/arch.sami to remove the health_uri / health_interval block, leaving only reverse_proxy 100.120.159.34:5000. Apply via caddy-apply (validates+reloads+commits atomically). Backup .bak-DC-084-pre created pre-edit; deleted after caddy-apply succeeded because the .bak file was being picked up by Caddy's import sites/* and causing an "ambiguous site definition" validation error.
  • impact: Eliminates 100% of recurring caddy journal spam from the dead Arch upstream. The dashboard's caddy-upstream-watcher.js continues to monitor the dead upstream correctly (now at consecutiveFailures: 1905+, lastSuccessAt: null, status: down, dead: true) — operators see the dead upstream in the dashboard, just without the journal noise. Future Caddyfile authors who add an active health check to a *.sami site will be unaware that they should not (since the dashboard handles monitoring), so a follow-up could add a CLAUDE.md note or a Caddyfile lint warning. Out of scope for this tick.
  • prerequisite: None. caddy-upstream-watcher.js already provides equivalent monitoring.
  • result: Shipped GLM-pending (Codex quota dead). Before/after on DNS2 (journalctl -u caddy --since "5 minutes ago" | grep health_checker.active | wc -l): before = ~30 entries / 5min (active probe every 10s, all failing); after = 0 entries / 5min. Live-verified: caddy validate succeeded (after removing .bak file that caused ambiguous site definition), Caddy reloaded via caddy-apply, route arch.sami → 100.120.159.34:5000 still active in admin API (verified via curl http://localhost:2019/config/apps/http/servers/srv0/routeshealth_uri: None, health_interval: None confirms the block is gone). Container dashcaddy-api Up About an hour (healthy) (no restart needed — only Caddyfile changed, not container). Live HTTP smoke all green: https://status.sami=200, https://dashcaddy.net=200, https://ca.sami=200, https://status.sami/api/health=401 (auth-gated, expected). Watcher state for 100.120.159.34:5000: consecutiveFailures: 1905, lastError: "probe timeout", status: down, dead: true — correctly tracked in /opt/dashcaddy/dashcaddy-api/data/caddy-upstreams.json. Backup deleted (would have caused site-definition ambiguity on next Caddy reload). Git: change lives only in DNS2's /etc/caddy/sites/arch.sami (the /etc/caddy git repo .gitignore excludes sites/ per design — only the main Caddyfile is tracked). The dashcaddy source repo (/root/dashcaddy) carries only this BACKLOG.md documentation update on branch dc/DC-084-arch-sami-caddy-healthcheck-removal.
  • Tests: No source code change; existing __tests__/caddy-upstream-watcher.test.js 26/26 pass (baseline preserved). 2465/2465 repo tests pass (4 pre-existing billing test suites fail with Cannot find module pdfkit — unrelated to this change).