From f4f2c26de350baf706779e65315ecbdb50c437fa Mon Sep 17 00:00:00 2001 From: Hermes Date: Thu, 29 Oct 2026 01:30:42 -0700 Subject: [PATCH] =?UTF-8?q?[grade=3DB=20urn:ump:35dpnggtxsqrxvlqm2d2buqkbn?= =?UTF-8?q?ggw3acflbccnkqpqyiiijcxx3q]=20fix(DC-170):=20MCP=20truth=20batc?= =?UTF-8?q?h=20=E2=80=94=206=20dead=20tools=20fixed,=20full-catalog=20drif?= =?UTF-8?q?t=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- AUDIT-2026-10-27.md | 102 +++++++++++++ DC-PRODUCTION-GRADE-BACKLOG.md | 21 +++ .../mcp/mcp-core-dc166-integration.test.js | 134 +++++++++++++++-- .../__tests__/mcp/mcp-core-dc166.test.js | 54 +++++++ .../routes/dns-records.routes.test.js | 83 +++++++++++ dashcaddy-api/jest.config.js | 5 + dashcaddy-api/routes/containers.js | 15 ++ dashcaddy-api/routes/dns.js | 15 ++ dashcaddy-api/src/mcp/mcp-core.js | 140 ++++++++++++++---- 9 files changed, 529 insertions(+), 40 deletions(-) create mode 100644 AUDIT-2026-10-27.md create mode 100644 dashcaddy-api/__tests__/routes/dns-records.routes.test.js diff --git a/AUDIT-2026-10-27.md b/AUDIT-2026-10-27.md new file mode 100644 index 0000000..880e0bc --- /dev/null +++ b/AUDIT-2026-10-27.md @@ -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, '', )` 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. diff --git a/DC-PRODUCTION-GRADE-BACKLOG.md b/DC-PRODUCTION-GRADE-BACKLOG.md index 0a139c0..cdb9a69 100644 --- a/DC-PRODUCTION-GRADE-BACKLOG.md +++ b/DC-PRODUCTION-GRADE-BACKLOG.md @@ -418,3 +418,24 @@ | 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 | | **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. diff --git a/dashcaddy-api/__tests__/mcp/mcp-core-dc166-integration.test.js b/dashcaddy-api/__tests__/mcp/mcp-core-dc166-integration.test.js index 3f9c489..2d704bd 100644 --- a/dashcaddy-api/__tests__/mcp/mcp-core-dc166-integration.test.js +++ b/dashcaddy-api/__tests__/mcp/mcp-core-dc166-integration.test.js @@ -1,12 +1,13 @@ /** - * DC-166 r2 (judge round 1 blocking demand): resolve the mounted - * method+path for the DC-166 MCP tool calls — no substring greps. + * DC-166 r2: resolve the mounted method+path for MCP tool calls — no + * substring greps. * - * SCOPE (merge-judge r1 blocker, urn:ump:2jdpii43xlasarlxl2jug64xab465xnzzbdt3bnd4fbr7y23l26q): - * this verifier covers the 11 NEW DC-166 tools (12 requests — security-summary - * fans out) — NOT the whole 28-tool catalog. The 17 pre-existing tools are - * resolved against declared routes by the follow-up MCP truth batch - * (audit 2026-10-27 #40: extend to every tool + fail on any unresolved path). + * SCOPE (DC-170 r2, audit #40 — FULL CATALOG): the EXPECTED table now covers + * EVERY tool's resolved (method, path) — the 11 DC-166 additions AND the 17 + * pre-existing tools (33 rows; check_health/container_action/deploy_app/ + * search_catalog contribute their distinct paths). The handler-derived test + * 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 * satisfied in-process (workflows — the one the judge flagged) and @@ -17,6 +18,9 @@ * against the route declarations extracted from the owning route file * (router.('') with the mount prefix from src/app.js) — * 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'; @@ -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/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' }, + // ── 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) { @@ -51,7 +80,9 @@ function extractRoutes(file) { for (const raw of src.split('\n')) { const line = raw.trim(); 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] || '/' }); } } @@ -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'); for (const e of EXPECTED) { 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 { 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)', () => { const segMatch = (decl, segs) => { const parts = decl.split('/').filter(Boolean); diff --git a/dashcaddy-api/__tests__/mcp/mcp-core-dc166.test.js b/dashcaddy-api/__tests__/mcp/mcp-core-dc166.test.js index 371e89a..aa50196 100644 --- a/dashcaddy-api/__tests__/mcp/mcp-core-dc166.test.js +++ b/dashcaddy-api/__tests__/mcp/mcp-core-dc166.test.js @@ -190,10 +190,64 @@ describe('DC-166 handler routing (captured fetchImpl)', () => { 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 () => { const { handleTool } = harness(); const r = await handleTool('dashcaddy_delete_everything', {}); expect(r.error).toBe(true); 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/); + }); }); diff --git a/dashcaddy-api/__tests__/routes/dns-records.routes.test.js b/dashcaddy-api/__tests__/routes/dns-records.routes.test.js new file mode 100644 index 0000000..88b92d8 --- /dev/null +++ b/dashcaddy-api/__tests__/routes/dns-records.routes.test.js @@ -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); + }); +}); diff --git a/dashcaddy-api/jest.config.js b/dashcaddy-api/jest.config.js index 1a7c200..6f5824f 100644 --- a/dashcaddy-api/jest.config.js +++ b/dashcaddy-api/jest.config.js @@ -1,5 +1,10 @@ module.exports = { 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, testMatch: ['**/__tests__/**/*.test.js'], collectCoverageFrom: [ diff --git a/dashcaddy-api/routes/containers.js b/dashcaddy-api/routes/containers.js index 35ecb3c..d1fe5ee 100644 --- a/dashcaddy-api/routes/containers.js +++ b/dashcaddy-api/routes/containers.js @@ -71,6 +71,21 @@ module.exports = function({ docker, log, asyncHandler, workflowEngine }) { 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 router.post('/:id/start', asyncHandler(async (req, res) => { const container = await getVerifiedContainer(req.params.id); diff --git a/dashcaddy-api/routes/dns.js b/dashcaddy-api/routes/dns.js index ba907be..e41aa1a 100644 --- a/dashcaddy-api/routes/dns.js +++ b/dashcaddy-api/routes/dns.js @@ -65,6 +65,21 @@ module.exports = function({ } }, '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) ===== // POST /universal/record — Create a DNS record via any provider diff --git a/dashcaddy-api/src/mcp/mcp-core.js b/dashcaddy-api/src/mcp/mcp-core.js index d88e1b5..f173db4 100644 --- a/dashcaddy-api/src/mcp/mcp-core.js +++ b/dashcaddy-api/src/mcp/mcp-core.js @@ -106,7 +106,7 @@ const TOOLS = [ // ── Deployment ── { 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: { type: 'object', properties: { @@ -189,7 +189,7 @@ const TOOLS = [ // ── 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: {} }, }, // ── Calendar (DC-166) ── @@ -257,7 +257,7 @@ const TOOLS = [ // ── DNS write (DC-166) ── { 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: { type: 'object', properties: { @@ -383,26 +383,42 @@ function createToolHandler({ baseUrl, apiKey, headers, fetchImpl, log }) { } 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': { + // 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) { - 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 ── case 'dashcaddy_system_health': { - // Public endpoint — no auth needed - const response = await doFetch(`${baseUrl}/api/v1/system/health`); - return response.json(); + // DC-170: via apiCall like every other tool (error envelope, no raw throw) + return apiCall('GET', '/system/health'); } case 'dashcaddy_system_metrics': { - const response = await doFetch(`${baseUrl}/api/v1/metrics/prometheus`); - return { metrics: await response.text() }; + // DC-170: was a raw doFetch().text() with no error envelope or cap + 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 ── @@ -413,15 +429,29 @@ function createToolHandler({ baseUrl, apiKey, headers, fetchImpl, log }) { case 'dashcaddy_container_action': { const { containerId, action } = args; - const method = action === 'remove' ? 'DELETE' : 'POST'; - return apiCall(method, `/containers/${encodeURIComponent(containerId)}/${encodeURIComponent(action)}`); + // DC-170: remove is DELETE /containers/:id — /containers/:id/remove never existed + if (action === 'remove') { + return apiCall('DELETE', `/containers/${encodeURIComponent(containerId)}`); + } + return apiCall('POST', `/containers/${encodeURIComponent(containerId)}/${encodeURIComponent(action)}`); } // ── Catalog & Discovery ── case 'dashcaddy_search_catalog': { let path = '/catalog'; 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)}`; return apiCall('GET', path); @@ -433,44 +463,86 @@ function createToolHandler({ baseUrl, apiKey, headers, fetchImpl, log }) { // ── Deployment ── 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)}`); 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 subdomain = args.subdomain || args.templateId; + const domain = `${subdomain}.${baseDomain}`; const caddy = await apiCall('POST', '/caddycode/generate', { - domain: `${subdomain}.sami`, + domain, upstream: `localhost:${port}`, websocket: true, cors: true, }); - // Step 3: Create service entry const service = await apiCall('POST', '/services', { id: subdomain, name: template.name, subdomain, - domain: `${subdomain}.sami`, - url: `https://${subdomain}.sami`, + domain, + url: `https://${domain}`, port, protocol: 'http', 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 { - deployed: !service.error, - service: service.error ? null : service, + registered: true, + service, caddyfile: caddy.error ? null : caddy.caddyfile, - url: `https://${subdomain}.sami`, - message: service.error - ? `Deployment failed: ${service.message}` - : `${template.name} deployed! Access it at https://${subdomain}.sami`, + url: `https://${domain}`, + 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.`, 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`, - `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 ── 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)}`; return apiCall('GET', path); } 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', { domain: args.domain, upstream: args.upstream, websocket: args.websocket, cors: args.cors, auth: args.auth, + authService: args.auth ? (args.authService || 'status') : undefined, }); } @@ -504,8 +581,9 @@ function createToolHandler({ baseUrl, apiKey, headers, fetchImpl, log }) { const findings = []; if (args.serviceId) { - // Service-specific diagnosis - const health = await apiCall('GET', `/services/${encodeURIComponent(args.serviceId)}/health`); + // Service-specific diagnosis (DC-170: /health/service/:id, not the + // never-existent /services/:id/health) + const health = await apiCall('GET', `/health/service/${encodeURIComponent(args.serviceId)}`); if (health.error) { findings.push({ severity: 'critical', message: `Cannot reach service: ${health.message}` }); } else {