[grade=B urn:ump:35dpnggtxsqrxvlqm2d2buqkbnggw3acflbccnkqpqyiiijcxx3q] fix(DC-170): MCP truth batch — 6 dead tools fixed, full-catalog drift gate

Audit P0-5 + deploy_app truth (batch 5 of the 2026-10-27 sweep, AUDIT-2026-10-27.md committed here):

ROUTE ADDITIONS:
- routes/containers.js GET / — container list (dockerode listContainers);
  the router had NO list route, so dashcaddy_list_containers 404'd always
- routes/dns.js GET /records — record list via active provider with a
  list-records capability guard (501 + guidance for push-only providers
  like RFC2136); dashcaddy_list_dns called GET /dns, never existed

HANDLER FIXES (src/mcp/mcp-core.js):
- get_service resolves from GET /services by id/name (single-service GET
  never existed) with a known-ids 404
- check_health + diagnose -> /health/service/:id and /health/services
  (both previously hit nonexistent routes)
- container_action remove -> DELETE /containers/:id (was /:id/remove)
- system_health/system_metrics via apiCall error envelope (metrics text
  capped at 64KB)
- search_catalog applies category filter client-side (/catalog/search
  has no category param — constraint was silently dropped)
- generate_caddyfile defaults authService=status when auth:true (auth
  flag alone silently produced UNGATED Caddyfiles)
- deploy_app honest re-contract: registers service + generates
  Caddyfile, does NOT claim image pull/container creation/deployed:true;
  base domain read from /config (hardcoded owner .sami TLD removed);
  caddy-code failure now surfaces as caddyfileError + FAILED message
- list_fleet description gains Premium hint

VERIFIER (audit #40): EXPECTED table 12 -> 34 rows covering EVERY tool's
(method,path); NEW Layer-3 handler-derived test drives the real
createToolHandler for all 28 tools and strictly resolves every captured
call mount-aware (a vacuous first draft of this gate was caught and
fixed pre-grade); extractRoutes regex extended to apiRouter.get inline
decls; Bearer credential-passthrough test added (DC-162 coverage gap)
NEW TESTS: dns-records.routes.test.js (5 route-level cases incl. 501
capability behavior); deploy_app partial-failure + no-.sami tests

HOST RELIABILITY (same sweep, evidenced here): jest maxWorkers 50% —
default 5-worker runs repeatedly OOM-killed this host's gateway
(dmesg 2026-10-29 00:58:40; swap was 4G/4G full). Gateway protected
separately via systemd OOMScoreAdjust=-500 drop-in + /swapfile2 (host
ops, graded B urn:ump:35dpnggtxsqrxvlqm2d2buqkbnggw3acflbccnkqpqyiiijcxx3q).
DC-171 DNS provider-parity ticket (Sami, plug-in-directly) appended to
backlog.

Full suite: 173/173 suites, 3740/3740 tests. Judge B r1
(urn:ump:jsobkg2t2omzvpinel2fufk7sspoy3vubsrth3e6lat3ygolvffq) blockers
all fixed: dns.js 501 arg order (the file aliases error() message-first
— the sweep's 'reversed-arg' finding was a false positive for its 7
sites; lesson #14), deploy_app partial-failure honesty, handler-derived
verifier.
This commit is contained in:
Hermes committed 2026-10-29 01:30:42 -07:00
1 parent 94ee76e9de
commit f4f2c26de3
9 files changed
+529 -40

No files matched your search

+102
View File
@@ -0,0 +1,102 @@
# DashCaddy Full Sweep — 2026-10-27
Branch: `dc/DC-168-calendar-widget` @ 6eaee81 · Suite: 3704/3705 passed (1 failure = date-expired fixture, fixed this sweep) · Lint: 12 errors (11 test-only), syntax clean.
**46 unique findings: 8 high · 16 medium · 18 low.** Method: 3 parallel audit agents (backend, frontend, product consistency) + jest + eslint; the top auth finding was **verified live** (sandbox boot + real minted API key). Cross-agent duplicates merged.
---
## P0 — Broken functionality (verified)
### 1. TOTP gate blocks ALL JWT/API-key auth when TOTP is enabled — HIGH (live-verified)
`src/utilities/middleware.js:531-598` — `totpAuthMiddleware` registers before `jwtApiKeyAuthMiddleware` and only checks session cookies; the JWT/API-key verifier never runs. Agent booted the real app with TOTP enabled, minted a valid `dk_` key, and `GET /api/v1/mcp/info` returned **401** with the TOTP gate's message. MCP-over-HTTP ("Connect your AI"), fleet endpoints, and every machine-credential integration are dead on any TOTP-protected install (i.e., production).
**Fix:** in the TOTP gate, attempt `verifyJWT`/`verifyAPIKey` before rejecting (or register the JWT/API-key gate first and let the TOTP gate pass through when `req.auth` is set). Tests missed it because `mcp.routes.test.js` injects `req.auth` directly.
### 2. Calendar widget writes all fail with 403 CSRF — HIGH
`status/js/calendar-widget.js:269-390` — all four mutations (discover / save / toggle / delete) use raw `fetch` with no `X-CSRF-Token`; backend `csrf-protection.js` rejects non-GETs with `[DC-100]`. Test Connection, Save, toggle, remove → all 403. The DC-168 feature we just shipped is write-broken.
**Fix:** route the calls through the existing `secureFetch` helper (injects token + auto-retries DC-100/101).
### 3. CSP silently kills inline onclick handlers — HIGH
`status/index.html:223,1051` — the meta CSP allows no `unsafe-inline`/`unsafe-hashes`, so `onclick=` attributes are blocked: **Disk Safety button, AI-chat FAB, and the injected Disk modal's Save/Clean-Up buttons do nothing** in all modern browsers.
**Fix:** wire `addEventListener` in `disk-settings.js` / `ai-chat.js` init; drop the inline attributes.
### 4. Workflows UI broken — double-prefix route bug still live; the fix exists on an unmerged branch — HIGH
`routes/workflows.js:37-78` declares `/workflows/...` while mounted at `/workflows` → `/api/v1/workflows/workflows/...`. Dashboard calls single-prefix → **every workflow toggle and Run Now 404s**. Premium-gated feature broken for paying customers. `openapi.yaml` documents the doubled paths (doc faithfully describing the bug).
**Root cause:** commit e524d16 (DC-166: workflows fix + MCP 17→28 tools) lives only on `dc/DC-166-mcp-completion` — **never merged into this branch** (verified via merge-base).
**Fix:** merge DC-166 into this branch, resolve mcp-core.js conflicts, re-run MCP suites.
### 5. Six MCP tools map to nonexistent routes — 404 at call time — HIGH
`src/mcp/mcp-core.js:268-300` — `get_service` (→GET /services/:serviceId ✗), `check_health` both modes (→/services/:id/health ✗, /health/all ✗), `list_containers` (→GET /containers ✗ no list route), `container_action remove` (→DELETE /containers/:id/remove ✗), `list_dns` (→GET /dns ✗). tools/list still advertises all of them to AI clients.
**Fix:** repoint to real routes (add `GET /containers` list route), and extend the route-existence test to EVERY tool (currently only the 11 DC-166 tools are covered — that's how this survived).
### 6. Calendar error path calls a nonexistent method — HIGH
`status/js/calendar-widget.js:329` — catch block calls `errorHandler.log()` which doesn't exist (`window.ErrorHandler` only has `logError`, and args are reversed anyway). The "Calendar offline" chip state is unreachable; every failed poll throws an unhandled rejection instead.
**Fix:** `errorHandler.logError('calendar-widget', e)`.
### 7. Calendar modal unusable on mobile — HIGH
`status/js/calendar-widget.js:18` — inline `min-width:560px` on the modal beats the ≤700px media query (inline styles win), so it overflows horizontally on phones. Codebase already needed `!important` for this exact pattern elsewhere.
**Fix:** size via a CSS class, drop the inline min-width.
### 8. `dashcaddy_deploy_app` MCP tool lies about deploying — MEDIUM (borderline high)
`src/mcp/mcp-core.js:318-358` — returns `deployed:true` + "Access it at https://plex.sami" but never pulls an image, creates a container, or writes a Caddyfile — only generates text and adds a dashboard entry; its own nextSteps tell the user to run docker manually. Also **hardcodes the owner's private `.sami` TLD into what would be customer deployments** (same hardcode in `app-selector.js:42`, `dns-template-selector.js:37,161`).
**Fix:** route through the real `POST /api/v1/apps/deploy`, or re-contract as a "plan deployment" helper; read the base domain from site config.
---
## P1 — Reliability & security
9. **MEDIUM — DNS check-update/update handlers hang.** `routes/dns.js:669-800`: bare `return`s, empty else/catch branches → request never answered until client timeout; non-JSON upstream dies in `JSON.parse` inside the swallowing catch. Fix: every path responds or throws typed.
10. **MEDIUM — Seven reversed-arg `errorResponse(res, '<msg>', <code>)` callsites** (`dns.js:624,735,817`, `errorlogs.js:219`, `ssl-monitor.js:95,99`, `disk-space.js:133`) hit the DC-062 guard → reachable paths 500 instead of intended 4xx/50x. This is the exact class DC-063 claimed fixed. Fix: reorder + grep gate to prevent regressions.
11. **MEDIUM — Calendar ICS fetch timeout silently ignored** — `routes/calendar.js:177` passes `opts.timeout`, which `src/utils/http.js` explicitly strips (undici); effective deadline 5s not 15s. Fix: `fetchT(url, {}, 15000)`.
12. **MEDIUM — StateManager writes are non-atomic.** `state-manager.js:85,138` truncate-then-write; crash mid-write corrupts services.json/config.json with no recovery (read only special-cases ENOENT). share-store already does tmp+rename correctly — reuse it.
13. **MEDIUM — Tailscale tailnet gate fails open** on daemon errors (`middleware.js:139-184`) — a tailscaled outage silently disables the IP allowlist while config claims enforcement. Fix: fail closed or make it loud.
14. **MEDIUM — WS auth fallback fails open.** `dashboard-ws.js:75-86` defaults to "any non-empty session cookie" when the real verifier isn't injected — the exact DC-061 footgun. Fix: default deny + loud log.
15. **MEDIUM — MCP `generate_caddyfile` auth flag is a silent no-op** (`mcp-core.js:375-383`) — "Enable SSO auth gate" produces a Caddyfile with NO auth gate, no error. Security-relevant for AI-generated configs.
16. **MEDIUM — SW offline fallback is dead code** (`status/sw.js:62-64`): `caches.match(...) || caches.match('/index.html')` — a Promise is always truthy, fallback never runs; plus 5xx responses get cached and later served as the app shell (no `response.ok` checks); plus install is all-or-nothing `addAll` of 36 URLs — one miss keeps the stale SW in control indefinitely.
17. **LOW — Host ingest keys HMAC'd with hardcoded fallback pepper** (`host-registry.js:97-103`) when the pepper file is missing — and no generation/persistence path exists at all.
18. **LOW — Calendar source update wipes the other credential type** (updating pass nulls token and vice versa) `routes/calendar.js:144-148`.
19. **LOW — Authenticated SSRF surface**: calendar sources + /discover accept localhost http + arbitrary https, status differentials allow internal port-scanning; no link-local/private-CIDR guard (169.254.169.254 over https).
20. **LOW — Public GET /api/v1/config returns raw config.json verbatim and 500s if corrupt** (`routes/config/settings.js:26-33`, route is in PUBLIC_ROUTES) — pairs badly with finding 12.
21. **LOW — MCP search_catalog drops the category filter when a query is present** (`mcp-core.js:304-311`).
22. **LOW — system_health/system_metrics tools bypass apiCall** — no timeout/size-cap/error envelope, unlike every other tool.
23. **LOW — Fleet tool 403s for free licenses with no premium hint in tools/list.**
## P2 — Frontend polish & races
24. **MEDIUM — In-flight guard silently drops forced refreshes** (`calendar-widget.js:284`) — save/toggle followed by a mid-flight poll = stale chip for up to 5 min.
25. **MEDIUM — openModal issues two overlapping fetch sequences** (no in-flight guard; last-response-wins can repaint with stale data).
26. **MEDIUM — Source toggle/delete handlers: unguarded fetch, no resp.ok check, optimistic re-render** — failures re-render as success.
27. **MEDIUM — `--cal-dot` theme overrides are dead CSS** — variables scoped to elements that are never ancestors of `.cal-dot` (`dashboard.css:4316,4347-4358`); the WCAG-tuned DC-168 dot colors never render.
28. **MEDIUM — Calendar chip keyboard-inaccessible; modal lacks Escape/click-outside/focus management** (`role=button` with no keydown; `display` set directly, bypassing wireModal).
29. **LOW — Calendar GETs bypass secureFetch** — no 15s timeout; one hung request wedges `refresh()` forever (pairs with 24).
30. **LOW — Polling ignores `document.hidden`** — 5-min calendar polls + 1s health polls run 24/7 in background tabs.
31. **LOW — log-insights.js still uses blocking `alert()` 6×** despite the toast convention.
32. **LOW — Windows path `C:\caddy\Caddyfile` prefilled on a Linux deployment** (`globals.js:39`).
33. **LOW — One malformed event start (null/invalid) crashes the cache merge** and flips the whole widget offline instead of skipping the event (`calendar-cache.js:8`).
## P3 — Professional/docs (the "polish" tier)
34. **HIGH (credibility) — DC-166 branch never merged** (see P0-4): capabilities manifest and changelog advertise a toolset this branch doesn't ship.
35. **MEDIUM — Changelog malformed**: two `[Unreleased]` sections; `[1.17.0]` before older entries; `[1.5.0]` after `[1.13.4]`; no `[1.16.0]` though package.json is 1.17.0; **internal judge grades + UMP URNs sitting in customer-facing copy**; DC-166 and DC-169 have no entries at all.
36. **MEDIUM — Three-way version drift**: VERSION file 1.15.0, package.json 1.17.0, openapi.yaml 1.15.0; start.sh stamps builds from the stale VERSION.
37. **MEDIUM — README quickstart endpoints omit `/v1` — every example 404s as written** (`README.md:283-300`).
38. **LOW — README says "50+ templates"; the measured catalog is 78** (DC-165 fixed marketing but not the repo README).
39. **LOW — API-SURFACE.md stale (2026-07-13, 285 vs 290 routes)** and documents the broken workflows paths as canonical; wrong PUBLIC_ROUTES line refs.
40. **MEDIUM — MCP mapping verifier covers only the 11 new tools** — extend the EXPECTED table to every tool and fail on any unresolved (method,path): turns tool↔route drift into a CI gate.
41. **LOW — Test-suite time bomb** (fixed this sweep): `calendar.routes.test.js` hardcoded `DTSTART:20261007` which silently expired out of the default window Oct 14 — fixture now builds tomorrow's date at runtime. Worth a repo-wide grep for hardcoded future dates in fixtures.
## Also this sweep
- Fixed + verified: calendar test fixture (15/15 green) — `__tests__/routes/calendar.routes.test.js`, uncommitted.
- Uncommitted in tree (pre-existing, not mine): `status/dist/features.js`, `status/sw.js`.
- Lint: 12 errors = 10× `no-eval` + 1× `no-new-func` in test files, 0 production errors remaining (credentials.js control-regex self-resolved in the response-helper refactor). 1027 warnings mostly unused-vars noise — a `warnings → 0` pass would be a cheap professionalism win.
## Recommended execution order
1. **Merge `dc/DC-166-mcp-completion`** into this branch (fixes P0-4, shrinks P0-5, restores toolset truth) → re-run MCP suites.
2. **Calendar widget fix batch**: secureFetch (CSRF+timeout), `errorHandler.logError`, modal CSS class, race/coalesce fixes, ARIA/Escape (P0-2, P0-6, P0-7, 24-30).
3. **TOTP/JWT gate ordering** (P0-1) + a chain-level integration test that exercises the real middleware chain, not injected `req.auth`.
4. **CSP event-handler wiring** (P0-3).
5. **MCP truth batch**: repoint dead tools, deploy_app re-contract + TLD config, auth-flag fix, full route-existence gate (P0-5, P0-8, 15, 21-23, 40).
6. **Reliability batch**: DNS hang, errorResponse arg order + grep gate, ICS timeout, atomic state writes, WS/tailscale fail-closed (9-14, 20).
7. **Docs/polish batch**: changelog rebuild, version single-sourcing, README/API-SURFACE regen, alert() → toast, Windows path (35-39, 31-32).
Every fix batch lands judged (Codex min B) + full suite + live-curl verification before commit, per standing policy.
+21
View File
@@ -418,3 +418,24 @@
| P5 | 8 (DC-101–108) | ~29 hr | Product vision: self-hosting platform | | P5 | 8 (DC-101–108) | ~29 hr | Product vision: self-hosting platform |
| P6 | 11 (DC-109–119) | ~49 hr | Shipdeck era: cross-platform & barrier removal | | P6 | 11 (DC-109–119) | ~49 hr | Shipdeck era: cross-platform & barrier removal |
| **Total** | **58** | **~159.5 hr** | | | **Total** | **58** | **~159.5 hr** | |
---
## DC-171 (P1) — DNS provider plug-in parity (Sami, 2026-10-28)
> "DashCaddy should work with any DNS server that is offered with the software — whatever one you choose it should be able to plug in directly."
- **status:** pending
- **current matrix:** technitium + cloudflare = full (create/delete/resolve/list-records/credentials/zones); **rfc2136 = push-only protocol — create/delete/resolve/credentials, NO list-records/zones/logs** (RFC 2136 has no query operation); manual = instructions only.
- **problem:** provider choice silently degrades the product: `dashcaddy_list_dns` → 501 and the DNS records UI shows nothing for RFC2136/manual users, discovered at call time instead of choice time.
- **fix shape:**
1. Protocol-possible parity for cloudflare/technitium verified method-for-method against the capability list (tests).
2. Capability reporting at CHOICE time: DNS provider selector shows per-provider capability badges; setup wizard warns before selecting a provider that can't list records.
3. MCP `tools/list` annotates DNS tools with the active provider's capabilities; handlers return actionable messages ("RFC2136 is push-only — records can be created and resolved, not listed; verify with dashcaddy_dns_remove_record + dig") instead of bare 501s.
4. Product-decision atomic sync: this ticket + code + tests + changelog land together.
- **provider expansion (Sami 2026-10-28: "What about bind 9 and could we add others"):**
- **BIND 9 (near-parity TODAY):** the existing rfc2136 adapter already speaks BIND's native dynamic-update protocol (nsupdate) — with a TSIG key + `allow-update` in named.conf, create/delete/resolve work on a BIND server immediately. `list-records` upgrade: BIND serves AXFR zone transfers when `allow-transfer` is configured — implement listRecords via AXFR (dnspython or `dig AXFR`) and the rfc2136 adapter becomes a genuine "BIND 9 provider". Optional deeper: local zone-file provider (edit /etc/bind/zones + `rndc reload`) when DashCaddy shares a host with named.
- **Straightforward full-parity adds (REST APIs, one adapter class each on the 9-capability interface):** PowerDNS Authoritative (excellent REST API — highest value), deSEC.io (free public REST), AdGuard Home (REST, rewrite rules), Acme DNS (REST), DuckDNS (simple REST).
- **Fundamentally limited (honest-report-only):** dnsmasq (hosts-file, no API), Windows DNS Server (PowerShell/RPC — separate windows-desktop-apps lane), CoreDNS (no record API; usually behind etcd/zone files).
- **Registry mechanics:** every new adapter = one class in src/dns/dns-providers/ implementing the capability subset it can support + registration in registry.js; tools/list, wizard, and DNS UI read capability metadata from registry.getProviderMeta() — no per-provider UI work needed.
- **impact:** choosing any shipped DNS provider gives a working product, with honest limits where the protocol itself forbids more; new providers plug in by writing one adapter class.
@@ -1,12 +1,13 @@
/** /**
* DC-166 r2 (judge round 1 blocking demand): resolve the mounted * DC-166 r2: resolve the mounted method+path for MCP tool calls — no
* method+path for the DC-166 MCP tool calls — no substring greps. * substring greps.
* *
* SCOPE (merge-judge r1 blocker, urn:ump:2jdpii43xlasarlxl2jug64xab465xnzzbdt3bnd4fbr7y23l26q): * SCOPE (DC-170 r2, audit #40 — FULL CATALOG): the EXPECTED table now covers
* this verifier covers the 11 NEW DC-166 tools (12 requests — security-summary * EVERY tool's resolved (method, path) — the 11 DC-166 additions AND the 17
* fans out) — NOT the whole 28-tool catalog. The 17 pre-existing tools are * pre-existing tools (33 rows; check_health/container_action/deploy_app/
* resolved against declared routes by the follow-up MCP truth batch * search_catalog contribute their distinct paths). The handler-derived test
* (audit 2026-10-27 #40: extend to every tool + fail on any unresolved path). * additionally drives the REAL createToolHandler and resolves every captured
* call, so handler drift fails even when this table goes stale.
* *
* Layer 1 (composition): boots the routers whose dependency surface can be * Layer 1 (composition): boots the routers whose dependency surface can be
* satisfied in-process (workflows — the one the judge flagged) and * satisfied in-process (workflows — the one the judge flagged) and
@@ -17,6 +18,9 @@
* against the route declarations extracted from the owning route file * against the route declarations extracted from the owning route file
* (router.<method>('<path>') with the mount prefix from src/app.js) — * (router.<method>('<path>') with the mount prefix from src/app.js) —
* an exact normalized match, so a double prefix or wrong method fails. * an exact normalized match, so a double prefix or wrong method fails.
* Layer 3 (DC-170 handler-derived): every TOOLS entry is invoked with sample
* args against a capture fetch; every emitted (method, path) must resolve
* to a declared route under the correct mount.
*/ */
'use strict'; 'use strict';
@@ -43,6 +47,31 @@ const EXPECTED = [
{ method: 'POST', path: '/api/v1/recipes/deploy', mount: '/recipes', file: 'routes/recipes/deploy.js', rel: '/deploy' }, { method: 'POST', path: '/api/v1/recipes/deploy', mount: '/recipes', file: 'routes/recipes/deploy.js', rel: '/deploy' },
{ method: 'POST', path: '/api/v1/workflows/disk-space-alert/run', mount: '/workflows', file: 'routes/workflows.js', rel: '/:workflowId/run' }, { method: 'POST', path: '/api/v1/workflows/disk-space-alert/run', mount: '/workflows', file: 'routes/workflows.js', rel: '/:workflowId/run' },
{ method: 'GET', path: '/api/v1/tailscale/status', mount: '/tailscale', file: 'routes/tailscale.js', rel: '/status' }, { method: 'GET', path: '/api/v1/tailscale/status', mount: '/tailscale', file: 'routes/tailscale.js', rel: '/status' },
// ── DC-170: full-catalog coverage (audit #40 / merge-judge r2 commitment) —
// every remaining tool's resolved (method, path). Sample segments stand in
// for :params. ──
{ method: 'GET', path: '/api/v1/services', mount: 'BARE', file: 'routes/services.js', rel: '/services' },
{ method: 'GET', path: '/api/v1/health/service/plex', mount: 'BARE', file: 'routes/health.js', rel: '/health/service/:id' },
{ method: 'GET', path: '/api/v1/health/services', mount: 'BARE', file: 'routes/health.js', rel: '/health/services' },
{ method: 'GET', path: '/api/v1/system/health', mount: 'BARE', file: 'routes/health.js', rel: '/system/health' },
{ method: 'GET', path: '/api/v1/metrics/prometheus', mount: 'BARE', file: 'src/app.js', rel: '/metrics/prometheus' },
{ method: 'GET', path: '/api/v1/containers', mount: '/containers', file: 'routes/containers.js', rel: '/' },
{ method: 'POST', path: '/api/v1/containers/abc123/start', mount: '/containers', file: 'routes/containers.js', rel: '/:id/start' },
{ method: 'POST', path: '/api/v1/containers/abc123/stop', mount: '/containers', file: 'routes/containers.js', rel: '/:id/stop' },
{ method: 'POST', path: '/api/v1/containers/abc123/restart', mount: '/containers', file: 'routes/containers.js', rel: '/:id/restart' },
{ method: 'POST', path: '/api/v1/containers/abc123/update', mount: '/containers', file: 'routes/containers.js', rel: '/:id/update' },
{ method: 'DELETE', path: '/api/v1/containers/abc123', mount: '/containers', file: 'routes/containers.js', rel: '/:id' },
{ method: 'GET', path: '/api/v1/catalog', mount: 'BARE', file: 'routes/catalog.js', rel: '/catalog' },
{ method: 'GET', path: '/api/v1/catalog/search', mount: 'BARE', file: 'routes/catalog.js', rel: '/catalog/search' },
{ method: 'GET', path: '/api/v1/catalog/plex', mount: 'BARE', file: 'routes/catalog.js', rel: '/catalog/:appId' },
{ method: 'GET', path: '/api/v1/discover', mount: 'BARE', file: 'routes/discover.js', rel: '/discover' },
{ method: 'POST', path: '/api/v1/wizard/recommend', mount: 'BARE', file: 'routes/wizard.js', rel: '/wizard/recommend' },
{ method: 'POST', path: '/api/v1/caddycode/generate', mount: 'BARE', file: 'routes/caddycode.js', rel: '/caddycode/generate' },
{ method: 'GET', path: '/api/v1/dns/records', mount: '/dns', file: 'routes/dns.js', rel: '/records' },
{ method: 'POST', path: '/api/v1/disaster/backup', mount: 'BARE', file: 'routes/disaster-recovery.js', rel: '/disaster/backup' },
{ method: 'GET', path: '/api/v1/disaster/status', mount: 'BARE', file: 'routes/disaster-recovery.js', rel: '/disaster/status' },
{ method: 'GET', path: '/api/v1/fleet/hosts', mount: 'BARE', file: 'routes/fleet.js', rel: '/fleet/hosts' },
{ method: 'GET', path: '/api/v1/config', mount: 'BARE', file: 'routes/config/settings.js', rel: '/config', bareMount: /apiRouter\.use\(configRoutes\(ctx\)\)/ },
]; ];
function extractRoutes(file) { function extractRoutes(file) {
@@ -51,7 +80,9 @@ function extractRoutes(file) {
for (const raw of src.split('\n')) { for (const raw of src.split('\n')) {
const line = raw.trim(); const line = raw.trim();
if (line.startsWith('//') || line.startsWith('*')) continue; // skip comments if (line.startsWith('//') || line.startsWith('*')) continue; // skip comments
for (const m of raw.matchAll(/router\.(get|post|put|delete|patch)\(\s*'([^']*)'/g)) { // (?:api)? Router — also matches inline `apiRouter.get(...)` declarations
// in src/app.js (DC-170: the prometheus metrics endpoint lives there).
for (const m of raw.matchAll(/(?:api)?[Rr]outer\.(get|post|put|delete|patch)\(\s*'([^']*)'/g)) {
out.push({ method: m[1].toUpperCase(), rel: m[2] || '/' }); out.push({ method: m[1].toUpperCase(), rel: m[2] || '/' });
} }
} }
@@ -67,13 +98,98 @@ describe('DC-166 r2: exact (method, path) resolution — no double prefixes', ()
const appSrc = fs.readFileSync(path.join(API, 'src/app.js'), 'utf8'); const appSrc = fs.readFileSync(path.join(API, 'src/app.js'), 'utf8');
for (const e of EXPECTED) { for (const e of EXPECTED) {
if (e.mount === 'BARE') { if (e.mount === 'BARE') {
expect(appSrc).toMatch(/apiRouter\.use\(capabilitiesRoute\.buildRouter\(\)\)/); // DC-170: BARE rows assert the actual module mount for their file so a
// silently-unmounted router can't pass the table (r1 judge blocker
// urn:ump:jsobkg2t2omzvpinel2fufk7sspoy3vubsrth3e6lat3ygolvffq asked
// for per-row BARE verification, not the old capabilities-only check).
expect(appSrc).toMatch(e.bareMount || /apiRouter\.use\(capabilitiesRoute\.buildRouter\(\)\)/);
} else { } else {
expect(appSrc).toContain(`apiRouter.use('${e.mount}'`); expect(appSrc).toContain(`apiRouter.use('${e.mount}'`);
} }
} }
}); });
// DC-170 (audit #40, merge-judge r2 blocker on the static table): derive the
// (method, path) from the REAL tool handler by driving createToolHandler
// against a capture fetch, then resolve every captured call against the
// union of all declared routes. This fails if a handler drifts even when
// the hand-written table above is stale.
const HANDLER_SAMPLE_ARGS = {
dashcaddy_get_service: { serviceId: 'plex' },
dashcaddy_check_health: {},
dashcaddy_system_health: {},
dashcaddy_system_metrics: {},
dashcaddy_list_containers: {},
dashcaddy_container_action: { containerId: 'abc123', action: 'restart' },
dashcaddy_search_catalog: {},
dashcaddy_discover_services: {},
dashcaddy_deploy_app: { templateId: 'plex' },
dashcaddy_wizard_recommend: { categories: ['media-streaming'] },
dashcaddy_list_dns: {},
dashcaddy_generate_caddyfile: { domain: 'x.example.com', upstream: 'localhost:80' },
dashcaddy_diagnose: {},
dashcaddy_create_backup: {},
dashcaddy_get_backup_status: {},
dashcaddy_list_fleet: {},
dashcaddy_calendar_events: {},
dashcaddy_calendar_add_source: { name: 'x', type: 'ics', url: 'https://c.example/x.ics' },
dashcaddy_security_events: {},
dashcaddy_security_summary: {},
dashcaddy_license_status: {},
dashcaddy_capabilities: {},
dashcaddy_dns_add_record: { domain: 'w.example.com', ip: '1.2.3.4' },
dashcaddy_dns_remove_record: { domain: 'w.example.com' },
dashcaddy_deploy_recipe: { recipeId: 'media-stack' },
dashcaddy_run_workflow: { workflowId: 'disk-space-alert' },
dashcaddy_tailscale_status: {},
};
test('every tool handler call resolves against a declared route (handler-derived)', async () => {
const { TOOLS, createToolHandler } = require('../../src/mcp/mcp-core');
const captured = [];
const fetchImpl = async (url, opts = {}) => {
captured.push({ method: (opts.method || 'GET').toUpperCase(), url: String(url) });
return { ok: true, status: 200, text: async () => JSON.stringify({ success: true }) };
};
const handleTool = createToolHandler({ baseUrl: 'http://127.0.0.1:1', apiKey: 'dk_driftgate', fetchImpl });
// Mount-aware resolver: each EXPECTED row owns (mount, file) — the mount
// test already proves app.js mounts it. Build (mount, decls) index.
const mounts = [];
for (const e of EXPECTED) {
let entry = mounts.find((m) => m.mount === e.mount && m.file === e.file);
if (!entry) { entry = { mount: e.mount, file: e.file, decls: extractRoutes(e.file).map((r) => ({ ...r, rel: normalize(r.rel) })) }; mounts.push(entry); }
}
const segMatchH = (decl, segs) => {
const parts = (decl || '').split('/').filter(Boolean);
if (parts.length !== segs.length) return false;
return parts.every((p, i) => p.startsWith(':') || p === segs[i]);
};
const resolveCall = (method, pathname) => {
for (const m of mounts) {
const prefix = `/api/v1${m.mount === 'BARE' ? '' : m.mount}`;
if (!pathname.startsWith(prefix)) continue;
const segs = pathname.slice(prefix.length).split('/').filter(Boolean);
const hit = m.decls.find((r) => r.method === method && segMatchH(r.rel, segs));
if (hit) return { mount: m.mount, file: m.file, rel: hit.rel };
}
return null;
};
for (const tool of TOOLS) {
captured.length = 0;
await handleTool(tool.name, HANDLER_SAMPLE_ARGS[tool.name] || {});
expect(captured.length).toBeGreaterThan(0); // every tool must hit the API
for (const c of captured) {
const u = new URL(c.url);
expect(u.pathname.startsWith('/api/v1/')).toBe(true);
const resolved = resolveCall(c.method, u.pathname);
// STRICT (r2 judge blocker on vacuous `hit || fallback`): any tool call
// resolving to no declared route must FAIL here.
expect(resolved || null).toEqual(expect.anything());
if (!resolved) throw new Error(`UNRESOLVED tool call: ${tool.name} -> ${c.method} ${u.pathname}`);
}
}
});
test('every MCP tool path resolves exactly to a declared route (method+pattern)', () => { test('every MCP tool path resolves exactly to a declared route (method+pattern)', () => {
const segMatch = (decl, segs) => { const segMatch = (decl, segs) => {
const parts = decl.split('/').filter(Boolean); const parts = decl.split('/').filter(Boolean);
@@ -190,10 +190,64 @@ describe('DC-166 handler routing (captured fetchImpl)', () => {
expect(calls[0].headers['x-api-key']).toBe('dk_' + 'test_key'); expect(calls[0].headers['x-api-key']).toBe('dk_' + 'test_key');
}); });
test('credential passthrough preserved for JWT callers (Bearer), not just x-api-key', async () => {
// Merge-judge r2 polish (urn:ump:5clu4udrbwuwxn74tqwxtukfjnrwkrbiykp6uh66ipipzxr32xsq):
// the DC-162 contract preserves the CALLER'S credential TYPE — exercise the
// Bearer path, which previously had zero coverage.
const { calls, handleTool } = harness({ headers: { Authorization: 'Bearer test_jwt' } });
await handleTool('dashcaddy_dns_add_record', { domain: 'w.example', ip: '1.1.1.1' });
expect(calls[0].headers['Authorization']).toBe('Bearer ' + 'test_jwt');
expect(calls[0].headers['x-api-key']).toBeUndefined();
});
test('unknown tool still rejected after catalog extension', async () => { test('unknown tool still rejected after catalog extension', async () => {
const { handleTool } = harness(); const { handleTool } = harness();
const r = await handleTool('dashcaddy_delete_everything', {}); const r = await handleTool('dashcaddy_delete_everything', {});
expect(r.error).toBe(true); expect(r.error).toBe(true);
expect(r.message).toMatch(/Unknown tool/); expect(r.message).toMatch(/Unknown tool/);
}); });
test('deploy_app reports partial failure when Caddyfile generation fails (DC-170)', async () => {
// r2 judge blocker (urn:ump:jsobkg2t2omzvpinel2fufk7sspoy3vubsrth3e6lat3ygolvffq):
// a caddy-code failure was swallowed — registered:true was returned while
// the message claimed the route was generated.
const calls = [];
const fetchImpl = async (url, opts = {}) => {
calls.push(String(url));
if (String(url).includes('/caddycode/generate')) {
return { ok: false, status: 500, text: async () => JSON.stringify({ error: 'caddy templating exploded' }) };
}
return { ok: true, status: 200, text: async () => JSON.stringify({
success: true, dashboardHost: 'status.example.net',
apps: [{ id: 'plex', name: 'Plex', category: 'media', image: 'plex/plex', ports: [32400] }],
}) };
};
const handleTool = createToolHandler({ baseUrl: 'http://127.0.0.1:1', apiKey: 'dk_x', fetchImpl });
const r = await handleTool('dashcaddy_deploy_app', { templateId: 'plex' });
expect(calls.filter((c) => c.includes('/config'))).toHaveLength(1); // base domain from /config, not hardcoded .sami
expect(r.registered).toBe(true);
expect(r.caddyfile).toBeNull();
expect(r.caddyfileError).toMatch(/caddy templating exploded/);
expect(r.message).toMatch(/FAILED/);
expect(r.url).toBe('https://plex.example.net');
});
test('deploy_app derives the base domain from /config (no hardcoded .sami)', async () => {
const calls = [];
const fetchImpl = async (url, opts = {}) => {
calls.push({ url: String(url), body: opts.body ? JSON.parse(opts.body) : undefined });
return { ok: true, status: 200, text: async () => JSON.stringify({
success: true, dashboardHost: 'status.example.net',
apps: [{ id: 'plex', name: 'Plex', category: 'media', image: 'plex/plex', ports: [32400] }],
caddyfile: 'generated-route',
}) };
};
const handleTool = createToolHandler({ baseUrl: 'http://127.0.0.1:1', apiKey: 'dk_x', fetchImpl });
const r = await handleTool('dashcaddy_deploy_app', { templateId: 'plex', subdomain: 'plex' });
expect(r.registered).toBe(true);
expect(r.url).toBe('https://plex.example.net');
const svcCall = calls.find((c) => c.url.includes('/services'));
expect(svcCall.body.domain).toBe('plex.example.net');
expect(JSON.stringify(svcCall.body)).not.toMatch(/\.sami/);
});
}); });
@@ -0,0 +1,83 @@
/**
* DC-170/DC-171: GET /api/v1/dns/records — the DNS record-listing route added
* so the MCP dashcaddy_list_dns tool (and any client) can list records via the
* ACTIVE provider, with honest capability behavior:
* - provider supports list-records -> { zone, count, records }
* - provider without list-records (e.g. RFC2136 push-only) -> 501 + guidance
* - no active provider resolvable -> 501
* Also pins the zone passthrough and the siteConfig.tld default.
*/
'use strict';
const express = require('express');
const request = require('supertest');
function buildApp(dnsDep, siteConfig) {
const dnsRoutes = require('../../routes/dns');
const app = express();
app.use(express.json());
app.use('/dns', dnsRoutes({
dns: dnsDep,
siteConfig: siteConfig || { tld: '.example.net' },
asyncHandler: (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next),
log: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} },
safeErrorMessage: (e) => (e && e.message) || 'error',
fetchT: async () => { throw new Error('not used here'); },
credentialManager: { retrieve: async () => null },
dnsPropagationChecker: {},
}));
return app;
}
const GOOD_PROVIDER = {
supportsCapability: (cap) => cap === 'list-records',
listRecords: jest.fn(async ({ zone }) => ({ records: [
{ name: `host.${zone}`, type: 'A', value: '10.0.0.5' },
{ name: `nas.${zone}`, type: 'A', value: '10.0.0.6' },
] })),
};
describe('GET /dns/records (DC-170 route + DC-171 capability behavior)', () => {
test('lists records via the active provider when list-records is supported', async () => {
const dns = { getActiveProvider: () => GOOD_PROVIDER };
const res = await request(buildApp(dns)).get('/dns/records');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.zone).toBe('example.net'); // from siteConfig.tld
expect(res.body.count).toBe(2);
expect(res.body.records[0]).toMatchObject({ type: 'A', value: '10.0.0.5' });
expect(GOOD_PROVIDER.listRecords).toHaveBeenCalledWith({ zone: 'example.net' });
});
test('zone query param overrides the siteConfig.tld default', async () => {
const dns = { getActiveProvider: () => GOOD_PROVIDER };
const res = await request(buildApp(dns)).get('/dns/records?zone=custom.zone');
expect(res.status).toBe(200);
expect(res.body.zone).toBe('custom.zone');
expect(GOOD_PROVIDER.listRecords).toHaveBeenCalledWith({ zone: 'custom.zone' });
});
test('provider WITHOUT list-records capability answers 501 with guidance', async () => {
// RFC2136-style: push-only protocol, cannot enumerate records
const dns = { getActiveProvider: () => ({ supportsCapability: () => false }) };
const res = await request(buildApp(dns)).get('/dns/records');
expect(res.status).toBe(501);
expect(String(res.body.error || res.body.message)).toMatch(/not supported/i);
});
test('missing/unresolvable active provider answers 501, does not crash', async () => {
const dns = {}; // no getActiveProvider at all
const res = await request(buildApp(dns)).get('/dns/records');
expect(res.status).toBe(501);
});
test('provider listRecords failure surfaces as 500 without leaking internals', async () => {
const dns = { getActiveProvider: () => ({
supportsCapability: () => true,
listRecords: async () => { throw new Error('upstream token expired with secret abc'); },
}) };
const res = await request(buildApp(dns)).get('/dns/records');
expect([500, 502]).toContain(res.status);
});
});
+5
View File
@@ -1,5 +1,10 @@
module.exports = { module.exports = {
testEnvironment: 'node', testEnvironment: 'node',
// DC-170: cap workers at half the cores — default (cores-1 = 5 workers ≈
// 1.5-3GB spike) repeatedly OOM-killed the host gateway (swap-full box,
// 12GB RAM, ~5.5GB always-on services). 50% keeps the suite fast (~2min)
// without the memory cliff.
maxWorkers: '50%',
testTimeout: 15000, testTimeout: 15000,
testMatch: ['**/__tests__/**/*.test.js'], testMatch: ['**/__tests__/**/*.test.js'],
collectCoverageFrom: [ collectCoverageFrom: [
+15
View File
@@ -71,6 +71,21 @@ module.exports = function({ docker, log, asyncHandler, workflowEngine }) {
return container; return container;
} }
// GET / — list containers (DC-170, audit P0-5: the router previously had NO
// list route, so the MCP dashcaddy_list_containers tool 404'd unconditionally)
router.get('/', asyncHandler(async (req, res) => {
const all = req.query.all !== 'false';
const containers = await docker.client.listContainers({ all });
success(res, {
count: containers.length,
containers: containers.map((c) => ({
id: c.Id, name: (c.Names && c.Names[0] ? c.Names[0].replace(/^\//, '') : c.Id.slice(0, 12)),
image: c.Image, state: c.State, status: c.Status,
created: c.Created, ports: c.Ports,
})),
});
}, 'containers-list'));
// Start container // Start container
router.post('/:id/start', asyncHandler(async (req, res) => { router.post('/:id/start', asyncHandler(async (req, res) => {
const container = await getVerifiedContainer(req.params.id); const container = await getVerifiedContainer(req.params.id);
+15
View File
@@ -65,6 +65,21 @@ module.exports = function({
} }
}, 'dns-provider-status')); }, 'dns-provider-status'));
// GET /records — list DNS records via the active provider (DC-170, audit
// P0-5: the MCP dashcaddy_list_dns tool called GET /dns, which never existed)
// NOTE: this file's errorResponse is responses.js `error(res, message, code)`
// (message FIRST) — the reversed-arg audit finding does not apply here.
router.get('/records', asyncHandler(async (req, res) => {
const zone = req.query.zone || (siteConfig.tld ? String(siteConfig.tld).replace(/^\./, '') : '');
const provider = dns.getActiveProvider ? dns.getActiveProvider() : null;
if (!provider || (provider.supportsCapability && !provider.supportsCapability('list-records'))) {
return errorResponse(res, 'Record listing not supported by current DNS provider', 501);
}
const result = await provider.listRecords({ zone });
const records = Array.isArray(result) ? result : (result && result.records) || [];
success(res, { zone, count: records.length, records });
}, 'dns-records-list'));
// ===== UNIVERSAL RECORD ENDPOINTS (work with any provider) ===== // ===== UNIVERSAL RECORD ENDPOINTS (work with any provider) =====
// POST /universal/record — Create a DNS record via any provider // POST /universal/record — Create a DNS record via any provider
+109 -31
View File
@@ -106,7 +106,7 @@ const TOOLS = [
// ── Deployment ── // ── Deployment ──
{ {
name: 'dashcaddy_deploy_app', name: 'dashcaddy_deploy_app',
description: 'Deploy a self-hosted application from the catalog. This is the main "self-host X" action. Pulls the Docker image, creates the container, generates a Caddyfile reverse proxy route, and adds the service to the dashboard. Returns the URL the user can access.', description: 'Register a self-hosted app from the catalog on this dashboard: adds the service entry and generates the Caddyfile reverse-proxy route for its URL. Does NOT pull images or create containers — run the container yourself using the returned nextSteps, then verify with dashcaddy_check_health. The URL uses this deployment\'s configured base domain.',
inputSchema: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
@@ -189,7 +189,7 @@ const TOOLS = [
// ── Fleet ── // ── Fleet ──
{ {
name: 'dashcaddy_list_fleet', name: 'dashcaddy_list_fleet',
description: 'List all hosts in the DashCaddy fleet (for multi-server management).', description: 'List all hosts in the DashCaddy fleet (for multi-server management). Requires the Premium Fleet feature — free-tier keys get 403.',
inputSchema: { type: 'object', properties: {} }, inputSchema: { type: 'object', properties: {} },
}, },
// ── Calendar (DC-166) ── // ── Calendar (DC-166) ──
@@ -257,7 +257,7 @@ const TOOLS = [
// ── DNS write (DC-166) ── // ── DNS write (DC-166) ──
{ {
name: 'dashcaddy_dns_add_record', name: 'dashcaddy_dns_add_record',
description: 'Create a DNS A record via the configured DNS provider (Technitium/universal). Points a hostname at a machine without touching the dashboard.', description: 'Create a DNS A record via the configured provider (Technitium/Cloudflare/RFC2136/universal). Points a hostname at a machine without touching the dashboard. List existing records first with dashcaddy_list_dns.',
inputSchema: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
@@ -383,26 +383,42 @@ function createToolHandler({ baseUrl, apiKey, headers, fetchImpl, log }) {
} }
case 'dashcaddy_get_service': { case 'dashcaddy_get_service': {
return apiCall('GET', `/services/${encodeURIComponent(args.serviceId)}`); // DC-170: GET /services/:serviceId never existed — resolve from the list
const data = await apiCall('GET', '/services');
if (data.error) return data;
const services = data.services || data.data || [];
const wanted = String(args.serviceId || '').toLowerCase();
const svc = services.find((s) =>
String(s.id || '').toLowerCase() === wanted || String(s.name || '').toLowerCase() === wanted);
if (!svc) {
return { error: true, status: 404,
message: `Service '${args.serviceId}' not found. Known ids: ${services.map((s) => s.id).join(', ') || '(none)'}` };
}
return { id: svc.id, name: svc.name, status: svc.status || 'unknown',
url: svc.url, subdomain: svc.subdomain, type: svc.type, port: svc.port };
} }
case 'dashcaddy_check_health': { case 'dashcaddy_check_health': {
// DC-170: real routes are /health/service/:id and /health/services —
// /services/:id/health and /health/all never existed (404 every call)
if (args.serviceId) { if (args.serviceId) {
return apiCall('GET', `/services/${encodeURIComponent(args.serviceId)}/health`); return apiCall('GET', `/health/service/${encodeURIComponent(args.serviceId)}`);
} }
return apiCall('GET', '/health/all'); return apiCall('GET', '/health/services');
} }
// ── System ── // ── System ──
case 'dashcaddy_system_health': { case 'dashcaddy_system_health': {
// Public endpoint — no auth needed // DC-170: via apiCall like every other tool (error envelope, no raw throw)
const response = await doFetch(`${baseUrl}/api/v1/system/health`); return apiCall('GET', '/system/health');
return response.json();
} }
case 'dashcaddy_system_metrics': { case 'dashcaddy_system_metrics': {
const response = await doFetch(`${baseUrl}/api/v1/metrics/prometheus`); // DC-170: was a raw doFetch().text() with no error envelope or cap
return { metrics: await response.text() }; const data = await apiCall('GET', '/metrics/prometheus');
if (data.error) return data;
const text = typeof data.raw === 'string' ? data.raw : JSON.stringify(data);
return { metrics: text.slice(0, 65536), truncated: text.length > 65536 };
} }
// ── Containers ── // ── Containers ──
@@ -413,15 +429,29 @@ function createToolHandler({ baseUrl, apiKey, headers, fetchImpl, log }) {
case 'dashcaddy_container_action': { case 'dashcaddy_container_action': {
const { containerId, action } = args; const { containerId, action } = args;
const method = action === 'remove' ? 'DELETE' : 'POST'; // DC-170: remove is DELETE /containers/:id — /containers/:id/remove never existed
return apiCall(method, `/containers/${encodeURIComponent(containerId)}/${encodeURIComponent(action)}`); if (action === 'remove') {
return apiCall('DELETE', `/containers/${encodeURIComponent(containerId)}`);
}
return apiCall('POST', `/containers/${encodeURIComponent(containerId)}/${encodeURIComponent(action)}`);
} }
// ── Catalog & Discovery ── // ── Catalog & Discovery ──
case 'dashcaddy_search_catalog': { case 'dashcaddy_search_catalog': {
let path = '/catalog'; let path = '/catalog';
if (args.query) { if (args.query) {
return apiCall('GET', `/catalog/search?q=${encodeURIComponent(args.query)}`); const results = await apiCall('GET', `/catalog/search?q=${encodeURIComponent(args.query)}`);
// DC-170: /catalog/search has no category param — apply it here so
// 'search media apps for plex' actually filters instead of silently
// dropping the constraint
if (!results.error && args.category && Array.isArray(results.apps)) {
const cat = String(args.category).toLowerCase();
results.apps = results.apps.filter((a) =>
String(a.category || '').toLowerCase().includes(cat));
results.results = results.apps.length;
results.category = args.category;
}
return results;
} }
if (args.category) path += `?category=${encodeURIComponent(args.category)}`; if (args.category) path += `?category=${encodeURIComponent(args.category)}`;
return apiCall('GET', path); return apiCall('GET', path);
@@ -433,44 +463,86 @@ function createToolHandler({ baseUrl, apiKey, headers, fetchImpl, log }) {
// ── Deployment ── // ── Deployment ──
case 'dashcaddy_deploy_app': { case 'dashcaddy_deploy_app': {
// Step 1: Get template details // DC-170 re-contract: this tool REGISTERS the service and generates the
// Caddyfile — it does NOT pull images or create containers (the old
// description and deployed:true claimed it did; its own nextSteps told
// the user to run docker manually). The public /config supplies the
// deployment's real base domain (was hardcoded to the owner's .sami).
const template = await apiCall('GET', `/catalog/${encodeURIComponent(args.templateId)}`); const template = await apiCall('GET', `/catalog/${encodeURIComponent(args.templateId)}`);
if (template.error) return template; if (template.error) return template;
// Step 2: Generate Caddyfile route let baseDomain = '';
const cfg = await apiCall('GET', '/config');
if (!cfg.error) {
const host = cfg.dashboardHost || (cfg.siteConfig && cfg.siteConfig.dashboardHost) || '';
if (String(host).includes('.')) {
baseDomain = String(host).split('.').slice(1).join('.');
} else if (cfg.tld) {
baseDomain = String(cfg.tld).replace(/^\./, '');
} else if (cfg.domain) {
baseDomain = String(cfg.domain).replace(/^\./, '');
}
}
if (!baseDomain) {
return { error: true, status: 409,
message: 'Cannot determine this deployment\'s base domain (no dashboardHost/tld in /config). Set it in the setup wizard, or pass an explicit subdomain + full domain via dashcaddy_generate_caddyfile.' };
}
const port = args.port || template.ports?.[0] || 8080; const port = args.port || template.ports?.[0] || 8080;
const subdomain = args.subdomain || args.templateId; const subdomain = args.subdomain || args.templateId;
const domain = `${subdomain}.${baseDomain}`;
const caddy = await apiCall('POST', '/caddycode/generate', { const caddy = await apiCall('POST', '/caddycode/generate', {
domain: `${subdomain}.sami`, domain,
upstream: `localhost:${port}`, upstream: `localhost:${port}`,
websocket: true, websocket: true,
cors: true, cors: true,
}); });
// Step 3: Create service entry
const service = await apiCall('POST', '/services', { const service = await apiCall('POST', '/services', {
id: subdomain, id: subdomain,
name: template.name, name: template.name,
subdomain, subdomain,
domain: `${subdomain}.sami`, domain,
url: `https://${subdomain}.sami`, url: `https://${domain}`,
port, port,
protocol: 'http', protocol: 'http',
type: template.category || 'generic', type: template.category || 'generic',
}); });
if (service.error) {
return { error: true, status: service.status || 500,
message: `Service registration failed: ${service.message}` };
}
// Judge blocker (urn:ump:jsobkg2t2omzvpinel2fufk7sspoy3vubsrth3e6lat3ygolvffq):
// a failed Caddyfile generation was swallowed — registered:true was
// returned while the message claimed the route was generated. Report
// the partial failure honestly.
if (caddy.error) {
return {
registered: true,
service,
caddyfile: null,
caddyfileError: caddy.message || 'unknown error',
url: `https://${domain}`,
message: `${template.name} registered on the dashboard, but Caddyfile route generation FAILED (${caddy.message || 'unknown error'}). The URL will not work until a reverse-proxy route exists.`,
nextSteps: [
`Pull the image: docker pull ${template.image || 'unknown'}`,
`Run the container with port ${port} mapped`,
'Create the reverse-proxy route (retry dashcaddy_generate_caddyfile for this domain, or add the route to Caddy manually) — the URL stays dead until then',
],
};
}
return { return {
deployed: !service.error, registered: true,
service: service.error ? null : service, service,
caddyfile: caddy.error ? null : caddy.caddyfile, caddyfile: caddy.error ? null : caddy.caddyfile,
url: `https://${subdomain}.sami`, url: `https://${domain}`,
message: service.error message: `${template.name} registered on the dashboard and Caddyfile route generated for https://${domain}. The container is NOT running yet — start it to make the URL live.`,
? `Deployment failed: ${service.message}`
: `${template.name} deployed! Access it at https://${subdomain}.sami`,
nextSteps: [ nextSteps: [
`Pull the Docker image: docker pull ${template.image || 'unknown'}`, `Pull the image: docker pull ${template.image || 'unknown'}`,
`Run the container with port ${port} mapped`, `Run the container with port ${port} mapped`,
`The Caddyfile route is configured — the URL should work once the container is running`, 'The Caddyfile route was generated — install it into Caddy if not auto-loaded, then check dashcaddy_check_health',
], ],
}; };
} }
@@ -484,18 +556,23 @@ function createToolHandler({ baseUrl, apiKey, headers, fetchImpl, log }) {
// ── DNS & Proxy ── // ── DNS & Proxy ──
case 'dashcaddy_list_dns': { case 'dashcaddy_list_dns': {
let path = '/dns'; // DC-170: GET /dns never existed — records live at GET /dns/records
let path = '/dns/records';
if (args.zone) path += `?zone=${encodeURIComponent(args.zone)}`; if (args.zone) path += `?zone=${encodeURIComponent(args.zone)}`;
return apiCall('GET', path); return apiCall('GET', path);
} }
case 'dashcaddy_generate_caddyfile': { case 'dashcaddy_generate_caddyfile': {
// DC-170: the route emits the auth gate only when auth && authService
// are BOTH set — auth:true alone was a silent no-op producing an
// UNGATED Caddyfile. Default the service to this dashboard.
return apiCall('POST', '/caddycode/generate', { return apiCall('POST', '/caddycode/generate', {
domain: args.domain, domain: args.domain,
upstream: args.upstream, upstream: args.upstream,
websocket: args.websocket, websocket: args.websocket,
cors: args.cors, cors: args.cors,
auth: args.auth, auth: args.auth,
authService: args.auth ? (args.authService || 'status') : undefined,
}); });
} }
@@ -504,8 +581,9 @@ function createToolHandler({ baseUrl, apiKey, headers, fetchImpl, log }) {
const findings = []; const findings = [];
if (args.serviceId) { if (args.serviceId) {
// Service-specific diagnosis // Service-specific diagnosis (DC-170: /health/service/:id, not the
const health = await apiCall('GET', `/services/${encodeURIComponent(args.serviceId)}/health`); // never-existent /services/:id/health)
const health = await apiCall('GET', `/health/service/${encodeURIComponent(args.serviceId)}`);
if (health.error) { if (health.error) {
findings.push({ severity: 'critical', message: `Cannot reach service: ${health.message}` }); findings.push({ severity: 'critical', message: `Cannot reach service: ${health.message}` });
} else { } else {