Compare commits

...
Author SHA1 Message Date
Hermes e524d1663d [pending-judge] feat(DC-166 r2): MCP tool-set expansion + workflows double-prefix route fix (17 -> 28 tools)
Owner directive "Do it" (2026-10-09): expand the MCP tool set so the
"manage your entire infrastructure" claim is true of the product. 11 new
tools over EXISTING routes: calendar (events/add_source), security
(events/summary), license_status, capabilities, DNS add/remove record,
deploy_recipe, run_workflow, tailscale_status. Handlers verified against
the actual route declarations; credential TYPE passthrough and the DC-162
machine-only tools/call gate unchanged (tests included).

judge r1 (grade C, urn:ump:fyvivcvrphjm5wk227ujr2yzfcgcdvwekjmw6lvnqvjosqrjhtbq)
blocking issues — all fixed in this revision:
1. WORKFLOWS DOUBLE-PREFIX (real product bug the judge caught):
   routes/workflows.js declared literal '/workflows/...' paths while
   src/app.js mounts the router at /workflows — composed path was
   /api/v1/workflows/workflows/..., which the SHIPPED dashboard bundle
   (status/js/bundled-workflows.js calls single-prefix /api/v1/workflows/)
   never hits: the workflows UI has been broken in production. Fixed by
   making route paths RELATIVE to the mount (same class as the recipes
   fix); run-workflow MCP path now matches. Also reordered /history
   before /:workflowId/history so the literal segment is not captured as
   an id (limit 100 vs 50 asserted in tests).
2. Verifier substring check replaced: NEW integration suite
   (__tests__/mcp/mcp-core-dc166-integration.test.js) resolves EVERY MCP
   tool call's complete mounted (method, path) against the declared route
   patterns extracted from the owning route file (params matched as
   patterns, comments excluded) — a double prefix or wrong method fails
   the run. Plus real-composition dispatch through the actual workflows
   router: POST /api/v1/workflows/:id/run reaches the run handler
   (sentinel '"ran":true'), list/history handlers land correctly, and a
   static guard fails any route file that re-declares its own mount name.
3. Both verbatim-command claims in the brief now name their file.

polish applied: calendar window-math test bounds tightened to exactly
~7 days (was a weak 6-hour lower bound); extra blank line before
tools/list removed; calendar_add_source schema gained the token property
the route already accepts (schema now matches the documented API).

Tests: 73 prior + 15 dc166 unit + 6 integration = 94 MCP tests green;
full suite 6 failed + 3697 passed == 3703 total (known-red billing
family, band-guarded; base-proof shows the same 6 red at a1b8fa3).
eslint: 0 errors on touched files (warnings count unchanged vs base).
2026-10-09 20:18:42 -07:00
Hermes a1b8fa36d9 chore(DC-165): record grade B (round 5) in changelog
urn:ump:oqgczb6zv5bemekbxko7gi5rvfle6yoshwgfwsx4mbbj4xmg6paq
2026-10-09 03:20:31 -07:00
Hermes 30e8055536 [pending-judge] feat(DC-165 rev5): product/site uniformity — fleet gate proven at composition root, single-source capabilities manifest
Site-vs-product audit (2026-10-09, owner decisions: "advertise as Premium"
and "It should be LLM readable") reconciles the entitlement triangle:
marketing copy <-> PREMIUM_FEATURES <-> license-server grant list.
(Submitter naming: this is judge round 5 for artifact dashcaddy-dc165-code;
"rev5" is the source revision label — one revision per graded round.)

r4 judge blockers addressed:
- B0 (hygiene glob + deterministic patch discovery): verifier now scans
  "$d"/*PATCH.diff and *.md/*.sh GLOBS instead of enumerated name lists —
  the judge worktree's 4_PATCH.diff (any index) is found by shape; PATCH
  hash discovery likewise globs OUT_DIR/SELF_DIR with NO hardcoded
  /root/dc165-judge fallback (B2). Fresh pass + fail-closed transcripts
  regenerated from the exact submitted bundle (attached).
- B1 (derivation honesty): openapi.yaml + capabilities.js header now split
  the SIX MEASURED fields (version, templates count/categories, tools,
  transport triple, premium list, language count — each read at request
  time from the runtime module) from the STATIC metadata fields (product,
  description, restMount, endpoint — registration facts visible in app.js,
  labeled as such). The no-drift claim attaches only to the measured set.
- B2: external hardcoded fallbacks removed (above).
- P0: revision labels synchronized (this paragraph).
- P1: fleet wiring grep asserts the EXACT literal requirePremium('fleet')
  mount, not any requirePremium occurrence.

Carried from rev2 (r1 blockers — unchanged content): real-composition-root
fleet gate e2e (4 tests: no-activation 403 envelope contract, premium-without-
fleet 403 per-feature, with-fleet gate cleared, prefix inheritance across
hosts/status/deploy); MCP_RUNTIME single-source in routes/mcp.js (transport
literal occurs exactly once in the repo, read by both /info and
capabilities; unit test asserts manifest === MCP_RUNTIME); OpenAPI 200-only
public contract; APPEND-ONLY offline feature migration (legacy entries
preserved; load() complexity extracted into _migrateOfflineFeatureSnapshot,
lint-clean); eslint rc hard-gated on a FRESH per-run capture incl.
middleware.js.

Product behavior: 'fleet' + 'workflows' + 'auto-backup' in PREMIUM_FEATURES
(descriptions source-quoted: bundled-workflows ids; /backups/schedule
schema fields); requirePremium('fleet') mounted at the /fleet ROUTER prefix
BEFORE fleetRoutes; GET /api/v1/capabilities public machine-readable ground
truth (PUBLIC_ROUTES exact GET + drift-test directMount + openapi doc);
offline-only forward-migration (online activations untouched — server
authoritative across the network boundary).

Diff: 9 files vs 4143bff (exact stats in EVIDENCE header).
Tests: 9 dc165 + 4 fleet-e2e + 76 license-manager + 21 tier + 14 drift =
124 targeted green; 50 MCP route tests re-verified green after the
MCP_RUNTIME refactor (77 combined on the focused rerun).
2026-10-09 02:56:41 -07:00
Hermes 4143bff8aa chore(DC-164): record grade B (round 6) in changelog
urn:ump:5vuahbn4znf7hrrshjvu2pcbqln5m6qcrpkkn3mx6y2rvvmdtqoa
2026-10-08 23:41:47 -07:00
14 changed files with 1195 additions and 20 deletions

No files matched your search

+43
View File
@@ -8,6 +8,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **DC-165 judge: grade B (round 5)** urn:ump:oqgczb6zv5bemekbxko7gi5rvfle6yoshwgfwsx4mbbj4xmg6paq
- **DC-165: product/site uniformity + LLM-readable capabilities.** Site-vs-product
audit reconciliation (owner: "advertise as Premium", "It should be LLM readable"):
'fleet' added to PREMIUM_FEATURES and `requirePremium('fleet')` mounted at the
/fleet ROUTER prefix (app.js) so every sub-route present and future inherits the
gate — proven by 4 real-boot composition-root e2e tests (free 403 envelope
contract, per-feature check, with-fleet gate cleared, hosts/status/deploy
inheritance). 'workflows' + 'auto-backup' (gated in routes forever, missing from
the constant so the offline path could never grant them) listed with
source-quoted descriptions; marketing pages now advertise them (manual backup +
Disaster Recovery stay free — copy matches code exactly). Offline activations
forward-migrate on load (append-only union, online untouched — server stays
authoritative). GET /api/v1/capabilities: public machine-readable manifest of the
RUNNING build — six measured fields (version, template count/categories, MCP
tools, transport identity triple, premium list, language count) derived at request
time from the runtime modules (single source: MCP_RUNTIME exported from
routes/mcp.js, read by /info and capabilities alike), static metadata fields
honestly labeled; PUBLIC_ROUTES + drift test + OpenAPI 200-only contract;
no-secrets negative test. License-server grant list extended to
sso/recipes/swarm/fleet/workflows/auto-backup with existing records backfilled
(ops). Marketing repo (dashcaddy.net, separate commits): counts 77→78 everywhere,
internal-identifier sweep (~85 private-TLD/IP/user/path hits → .home/example
style), radicale install guide (78th), calendar/Connect-AI advertised, MCP claim
softened to real scope + capabilities pointer, llms.txt → runtime ground truth,
robots LLMs-Txt line. 124 targeted tests (9 dc165 + 4 fleet-e2e + 76 license +
21 tier + 14 drift); 73 MCP tests re-green after refactor.
- **DC-164 judge: grade B (round 6)** urn:ump:5vuahbn4znf7hrrshjvu2pcbqln5m6qcrpkkn3mx6y2rvvmdtqoa
- **DC-164: operator env overrides + license-material untracking.** start.sh
forwards `-e` flags from /etc/dashcaddy/operator.env (root-owned KEY=VALUE,
0600) into the container via scripts/operator-flags.js: one pinned fd
(lstat pre-check → open O_RDONLY|O_NOFOLLOW|O_NONBLOCK → fstat+read on the
same descriptor) so symlinks, FIFOs/devices, group/other-readable or
special-bit modes, and foreign ownership are refused (exit 3, fail closed:
warn + forward nothing; any other helper failure ABORTS startup, fail
loud). Canonical flag ALLOW_LIFETIME_LICENSE=true — creator hosts only;
it is a deployment rail, not the boundary: offline lifetime codes must
HMAC-verify against the signing secret which is never shipped to customers
(installs carry .license-secret-hash). 17 executed tests (FIFO non-block
regression, O_NOFOLLOW symlink-to-valid-target, mode/owner matrix,
injection-inert values). Root-cause fix for the 2026-10-08 incident:
data/services.json, .license-secret, .license-counter UNTRACKED +
.gitignore'd — deploy checkouts can no longer clobber live runtime state.
- **DC-163 judge: grade B (round 2)** urn:ump:l2tg66wmchkmd6fde4k3ojwc56zpxqh657spe37bu6dpvduswalq
- **DC-163: AI assistant that actually works — LLM intent engine + fixed
chat panel.** The 🤖 floating assistant was broken end-to-end: the
@@ -0,0 +1,201 @@
/**
* DC-165: site/product uniformity bundle — tests.
* 1. fleet is a gated premium feature (403 free / pass premium, and the
* gate is mounted at ROUTER level so new sub-routes inherit it).
* 2. offline activations minted before 'fleet' existed are forward-
* migrated on load (creator lifetime key case).
* 3. GET /api/v1/capabilities returns the running build's ground truth
* derived from source modules (no drift by construction).
*/
'use strict';
const express = require('express');
function buildGatedRouter(licenseLike) {
const { LicenseManager } = require('../src/managers/license-manager');
// Real requirePremium() against a duck-typed instance: own properties
// (hasFeature/isExpired/log) win over prototype defaults.
const lm = Object.assign(Object.create(LicenseManager.prototype), {
log: { warn() {} },
isExpired: () => false,
}, licenseLike);
const router = express.Router();
router.use(lm.requirePremium('fleet'));
router.get('/fleet/hosts', (_req, res) => res.json({ ok: true }));
return router;
}
describe('DC-165 fleet premium gate', () => {
test('PREMIUM_FEATURES includes fleet (site entitlement contract)', () => {
const { PREMIUM_FEATURES } = require('../src/managers/license-manager');
expect(PREMIUM_FEATURES.fleet).toBeDefined();
expect(PREMIUM_FEATURES.fleet.name).toMatch(/Fleet/i);
});
test('free license: GET /fleet/hosts -> 403 premiumRequired fleet', async () => {
const app = express();
app.use('/api/v1', buildGatedRouter({
hasFeature: () => false,
isExpired: () => false,
log: { warn() {} },
}));
const srv = await new Promise((r) => { const s = app.listen(0, () => r(s)); });
try {
const res = await fetch(`http://127.0.0.1:${srv.address().port}/api/v1/fleet/hosts`);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.premiumRequired).toBe(true);
expect(body.feature).toBe('fleet');
} finally { srv.close(); }
});
test('premium license with fleet: request passes the gate', async () => {
const app = express();
app.use('/api/v1', buildGatedRouter({
hasFeature: (f) => f === 'fleet',
isExpired: () => false,
log: { warn() {} },
}));
const srv = await new Promise((r) => { const s = app.listen(0, () => r(s)); });
try {
const res = await fetch(`http://127.0.0.1:${srv.address().port}/api/v1/fleet/hosts`);
expect(res.status).toBe(200);
} finally { srv.close(); }
});
test('hasFeature fallback covers fleet for activations without explicit features list', () => {
const { LicenseManager, PREMIUM_FEATURES } = require('../src/managers/license-manager');
const lm = Object.create(LicenseManager.prototype);
lm.activation = { code: 'DC-X', expiresAt: '2099-01-01T00:00:00Z', features: undefined };
lm.isExpired = () => false;
expect(lm.hasFeature('fleet')).toBe(true);
expect(Object.keys(PREMIUM_FEATURES)).toContain('fleet');
});
});
describe('DC-165 offline activation forward-migration', () => {
test('load() adds missing premium features to an offline activation and persists', async () => {
const { LicenseManager, PREMIUM_FEATURES } = require('../src/managers/license-manager');
const stored = {
code: 'DC-OLD-OLD-OLD-OLD-OLD',
codeId: 1,
durationDays: 0,
lifetime: true,
activatedAt: '2026-01-01T00:00:00.000Z',
expiresAt: '2099-12-31T23:59:59.999Z',
machineId: 'deadbeefdeadbeef',
validationMethod: 'offline',
features: ['sso', 'recipes', 'swarm'], // pre-fleet snapshot
};
let persisted = null;
const lm = new LicenseManager(
{
retrieve: async () => JSON.stringify(stored),
store: async (key, value) => { persisted = { key, value }; },
delete: async () => {},
},
'/tmp/dc165-nonexistent-config.json', // no config fallback path
{ info() {}, warn() {}, error() {} }
);
await lm.load();
expect(lm.activation.features).toEqual(Object.keys(PREMIUM_FEATURES));
expect(persisted).not.toBeNull();
expect(persisted.key).toBe('license.activation');
expect(JSON.parse(persisted.value).features).toContain('fleet');
});
test('migration is APPEND-ONLY: unknown legacy entries are preserved (judge P0)', async () => {
const { LicenseManager, PREMIUM_FEATURES } = require('../src/managers/license-manager');
const stored = {
code: 'DC-LEG-LEG-LEG-LEG-LEG', codeId: 3, durationDays: 0, lifetime: true,
activatedAt: '2026-01-01T00:00:00.000Z',
expiresAt: '2099-12-31T23:59:59.999Z',
machineId: 'cafe0000deadbeef',
validationMethod: 'offline',
features: ['sso', 'recipes', 'swarm', 'legacy-beta-widget'], // extra rides along
};
let persisted = null;
const lm = new LicenseManager(
{ retrieve: async () => JSON.stringify(stored), store: async (key, value) => { persisted = { key, value }; }, delete: async () => {} },
'/tmp/dc165-nonexistent-config.json',
{ info() {}, warn() {}, error() {} }
);
await lm.load();
expect(lm.activation.features).toEqual(Object.keys(PREMIUM_FEATURES).concat(['legacy-beta-widget']));
expect(JSON.parse(persisted.value).features).toContain('legacy-beta-widget');
});
test('online activations are NOT migrated (server is authoritative)', async () => {
const { LicenseManager } = require('../src/managers/license-manager');
const activatedAt = new Date(Date.now() - 24 * 3600e3).toISOString(); // yesterday
const stored = {
code: 'DC-ONLINE', codeId: 2, durationDays: 365,
activatedAt,
expiresAt: new Date(Date.now() + 340 * 864e5).toISOString(), // within activatedAt+366d bound
lastOnlineValidatedAt: activatedAt,
machineId: 'aabbccddeeff0011',
validationMethod: 'online',
features: ['sso', 'recipes', 'swarm'],
serverToken: 'tok',
};
let storedCount = 0;
const lm = new LicenseManager(
{
retrieve: async () => JSON.stringify(stored),
store: async () => { storedCount++; },
delete: async () => {},
},
'/tmp/dc165-nonexistent-config.json',
{ info() {}, warn() {}, error() {} }
);
// No LICENSE_SERVER_URL => _validateLoadedActivationForServerMode returns true immediately.
await lm.load();
expect(lm.activation.features).toEqual(['sso', 'recipes', 'swarm']); // untouched
expect(storedCount).toBe(0); // nothing persisted
});
});
describe('DC-165 capabilities endpoint', () => {
let srv;
let base;
beforeAll(async () => {
const cap = require('../routes/capabilities');
const app = express();
app.use('/api/v1', cap.buildRouter());
srv = await new Promise((r) => { const s = app.listen(0, () => r(s)); });
base = `http://127.0.0.1:${srv.address().port}/api/v1`;
});
afterAll(() => srv && srv.close());
test('returns source-derived ground truth with zero drift from modules', async () => {
const res = await fetch(`${base}/capabilities`);
expect(res.status).toBe(200);
const j = await res.json();
const { APP_TEMPLATES } = require('../src/docker/app-templates');
const { TOOLS } = require('../src/mcp/mcp-core');
const { PREMIUM_FEATURES } = require('../src/managers/license-manager');
expect(j.success).toBe(true);
expect(j.product).toBe('dashcaddy');
expect(j.templates.count).toBe(Object.keys(APP_TEMPLATES).length);
expect(j.templates.count).toBeGreaterThanOrEqual(78);
const { MCP_RUNTIME } = require('../routes/mcp');
expect(j.mcp.transport).toBe(MCP_RUNTIME.transport);
expect(j.mcp.protocolVersion).toBe(MCP_RUNTIME.protocolVersion);
expect(j.mcp.protocolVersions).toEqual(MCP_RUNTIME.supportedProtocolVersions);
expect(j.mcp.tools).toEqual(TOOLS.map((t) => t.name).sort());
expect(j.premiumFeatures).toEqual(Object.keys(PREMIUM_FEATURES).sort());
expect(j.premiumFeatures).toContain('fleet');
expect(j.i18n.languageCount).toBe(31);
expect(j.version).toMatch(/^\d+\.\d+\.\d+/);
});
test('no secrets or host-specific data in the payload', async () => {
const res = await fetch(`${base}/capabilities`);
const text = await res.text();
expect(text).not.toMatch(/license\/(activation|secret)/i);
expect(text).not.toMatch(/sami/i);
expect(text).not.toMatch(/100\.|192\.168|dashcaddy\.net|status\.sami/i);
expect(text).not.toMatch(/"token"|"api_?key"|"password"|"secret"/i);
});
});
@@ -0,0 +1,179 @@
/**
* DC-165 rev2 (judge r1 B2): composition-root integration proof for the
* fleet premium gate. Boots the REAL createApp() (src/app.js) on an
* ephemeral port — no synthetic router — and probes /api/v1/fleet/hosts:
*
* 1. no activation -> 403 premiumRequired, feature 'fleet'
* 2. activation WITHOUT fleet -> still 403 (proves the check is per-feature)
* 3. activation WITH fleet -> NOT the 403 premium envelope (200 ok or
* a downstream error, e.g. 500 from a
* missing hosts file in the temp data dir —
* anything except the gate rejection)
*
* The gate middleware (requirePremium) closes over the same LicenseManager
* instance that createApp returns (src/app.js line ~184/1399), so mutating
* `.activation` on that instance after boot exercises exactly what a loaded
* activation would do at request time. The 403 envelope shape is asserted
* byte-identical to requirePremium's errorResponse contract
* (premiumRequired/feature/featureName/currentTier/upgradeUrl).
*/
'use strict';
const { spawn } = require('child_process');
const path = require('path');
const os = require('os');
const fs = require('fs');
const ROOT = path.join(__dirname, '..', '..'); // dashcaddy-api/
const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dc165-fleet-e2e-'));
const BOOT_CODE = `
process.env.DATA_DIR = ${JSON.stringify(path.join(DATA_DIR, 'data'))};
process.env.SERVICES_FILE = ${JSON.stringify(path.join(DATA_DIR, 'services.json'))};
process.env.TOTP_CONFIG_FILE = ${JSON.stringify(path.join(DATA_DIR, 'totp-config.json'))};
const { createApp } = require(${JSON.stringify(path.join(ROOT, 'src', 'app'))});
createApp().then(async (created) => {
const app = created.app || created;
globalThis.__licenseManager = created.licenseManager;
const authManager = require(${JSON.stringify(path.join(ROOT, 'src', 'managers', 'auth-manager'))});
let jwt = '';
try {
jwt = await authManager.generateJWT({ sub: 'e2e-test', scope: ['admin'] }, '1h');
} catch (e) {
jwt = '';
}
const server = app.listen(0, '127.0.0.1', () => {
console.log('JWT:' + jwt);
console.log('PORT:' + server.address().port);
console.log('READY');
});
// control channel on stdout: JSON lines the test writes to stdin
require('readline').createInterface({ input: process.stdin }).on('line', (line) => {
let msg; try { msg = JSON.parse(line); } catch (_) { return; }
if (msg.op === 'setActivation') {
if (msg.activation === null) { globalThis.__licenseManager.activation = null; }
else { globalThis.__licenseManager.activation = msg.activation; }
console.log('ACK:setActivation');
}
});
process.on('SIGTERM', () => server.close(() => process.exit(0)));
process.on('SIGINT', () => server.close(() => process.exit(0)));
}).catch((e) => { console.error('BOOT-FAIL', e && e.message); process.exit(1); });
`;
let child;
let PORT;
let JWT;
function boot() {
return new Promise((resolve, reject) => {
const c = spawn(process.execPath, ['-e', BOOT_CODE], { cwd: ROOT, stdio: ['pipe', 'pipe', 'pipe'] });
let buf = '';
const to = setTimeout(() => { try { c.kill('SIGKILL'); } catch (_) {} reject(new Error('boot timeout')); }, 30000);
c.stdout.on('data', (d) => {
buf += String(d);
const mp = buf.match(/PORT:(\d+)/);
const mj = buf.match(/JWT:(\S*)/);
if (mp && buf.includes('READY')) {
clearTimeout(to);
c.port = Number(mp[1]);
c.jwt = mj ? mj[1] : '';
resolve(c);
}
});
c.stderr.on('data', (d) => process.stderr.write('[boot] ' + d));
c.on('exit', (code) => { if (!c.port) { clearTimeout(to); reject(new Error('boot exited ' + code)); } });
});
}
function probeFleet() {
return fetch(`http://127.0.0.1:${PORT}/api/v1/fleet/hosts`, {
redirect: 'manual',
headers: JWT ? { authorization: `Bearer ${JWT}` } : {},
}).then(async (r) => ({ status: r.status, body: await r.text() }));
}
function send(msg) {
return new Promise((resolve) => {
const onData = (d) => {
if (String(d).includes('ACK:')) { child.stdout.removeListener('data', onData); resolve(); }
};
child.stdout.on('data', onData);
child.stdin.write(JSON.stringify(msg) + '\n');
});
}
const BASE_ACT = {
code: 'DC-TEST-TEST-TEST-TEST-TEST',
codeId: 99,
durationDays: 365,
activatedAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 300 * 864e5).toISOString(),
lastOnlineValidatedAt: new Date().toISOString(),
machineId: 'e2e00000000000aa',
validationMethod: 'online',
features: [],
serverToken: 'tok',
};
describe('DC-165 fleet gate through the real composition root', () => {
beforeAll(async () => {
child = await boot();
PORT = child.port;
JWT = child.jwt || '';
if (!Number.isInteger(PORT) || PORT <= 0) throw new Error('no bound port');
}, 45000);
afterAll(() => {
if (child) { try { child.kill('SIGTERM'); } catch (_) {} }
fs.rmSync(DATA_DIR, { recursive: true, force: true });
});
test('1) no activation -> 403 premiumRequired fleet', async () => {
await send({ op: 'setActivation', activation: null });
const r = await probeFleet();
expect(r.status).toBe(403);
const j = JSON.parse(r.body);
expect(j.premiumRequired).toBe(true);
expect(j.feature).toBe('fleet');
expect(j.featureName).toMatch(/Fleet/);
expect(j.currentTier).toBe('free');
});
test('2) premium activation WITHOUT fleet -> still 403 fleet (per-feature)', async () => {
await send({ op: 'setActivation', activation: { ...BASE_ACT, features: ['sso', 'recipes'] } });
const r = await probeFleet();
expect(r.status).toBe(403);
expect(JSON.parse(r.body).feature).toBe('fleet');
});
test('3) premium activation WITH fleet -> gate passes (not the 403 envelope)', async () => {
await send({ op: 'setActivation', activation: { ...BASE_ACT, features: ['sso', 'recipes', 'fleet'] } });
const r = await probeFleet();
// gate cleared; downstream handler may 200 (empty hosts file gets created)
// or surface an unrelated data-dir issue — it must NOT be the gate 403.
const gateReject = r.status === 403 && /premiumRequired/.test(r.body);
expect(gateReject).toBe(false);
expect([200, 500]).toContain(r.status);
if (r.status === 200) {
const j = JSON.parse(r.body);
expect(Array.isArray(j.hosts)).toBe(true);
}
});
test('4) gate is inherited by the whole /fleet prefix, not just /hosts', async () => {
await send({ op: 'setActivation', activation: null });
for (const p of ['/api/v1/fleet/status', '/api/v1/fleet/deploy']) {
const r = await fetch(`http://127.0.0.1:${PORT}${p}`, {
method: p.endsWith('/deploy') ? 'POST' : 'GET',
headers: { 'Content-Type': 'application/json', ...(JWT ? { authorization: `Bearer ${JWT}` } : {}) },
body: p.endsWith('/deploy') ? '{}' : undefined,
redirect: 'manual',
});
const body = await r.text();
expect(r.status).toBe(403);
expect(JSON.parse(body).feature).toBe('fleet');
}
});
});
@@ -0,0 +1,161 @@
/**
* DC-166 r2 (judge round 1 blocking demand): resolve the COMPLETE mounted
* method+path for the MCP tool routes — no substring greps.
*
* Layer 1 (composition): boots the routers whose dependency surface can be
* satisfied in-process (workflows — the one the judge flagged) and
* dispatches synthetic requests, asserting the composed path from the
* MCP tool reaches the intended handler (sentinel body), plus ordering
* (literal /history vs /:id/history).
* Layer 2 (exact table): every MCP tool call's (method, path) is resolved
* against the route declarations extracted from the owning route file
* (router.<method>('<path>') with the mount prefix from src/app.js) —
* an exact normalized match, so a double prefix or wrong method fails.
*/
'use strict';
const express = require('express');
const fs = require('fs');
const path = require('path');
const API = path.join(__dirname, '..', '..');
// ── exact expected contract for each DC-166 tool call ────────────────
// (method, full path the tool's apiCall produces) -> (mount, route file,
// relative route decl expected AFTER prefix-stripping)
const EXPECTED = [
{ method: 'GET', path: '/api/v1/calendar/events', mount: '/calendar', file: 'routes/calendar.js', rel: '/events' },
{ method: 'POST', path: '/api/v1/calendar/sources', mount: '/calendar', file: 'routes/calendar.js', rel: '/sources' },
{ method: 'GET', path: '/api/v1/security/events', mount: '/security', file: 'routes/security.js', rel: '/events' },
{ method: 'GET', path: '/api/v1/security/events/stats', mount: '/security', file: 'routes/security.js', rel: '/events/stats' },
{ method: 'GET', path: '/api/v1/security/events/perimeter', mount: '/security', file: 'routes/security.js', rel: '/events/perimeter' },
{ method: 'GET', path: '/api/v1/license/status', mount: '/license', file: 'routes/license.js', rel: '/status' },
{ method: 'GET', path: '/api/v1/capabilities', mount: 'BARE', file: 'routes/capabilities.js', rel: '/' },
{ method: 'POST', path: '/api/v1/dns/record', mount: '/dns', file: 'routes/dns.js', rel: '/record' },
{ method: 'DELETE', path: '/api/v1/dns/record', mount: '/dns', file: 'routes/dns.js', rel: '/record' },
{ 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' },
];
function extractRoutes(file) {
const src = fs.readFileSync(path.join(API, file), 'utf8');
const out = [];
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)) {
out.push({ method: m[1].toUpperCase(), rel: m[2] || '/' });
}
}
return out;
}
function normalize(p) {
return p.replace(/\/index$/, '') || '/';
}
describe('DC-166 r2: exact (method, path) resolution — no double prefixes', () => {
test('app.js really mounts every prefix the table claims', () => {
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\(\)\)/);
} else {
expect(appSrc).toContain(`apiRouter.use('${e.mount}'`);
}
}
});
test('every MCP tool path resolves exactly to a declared route (method+pattern)', () => {
const segMatch = (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]);
};
for (const e of EXPECTED) {
const routes = extractRoutes(e.file).map((r) => ({ ...r, rel: normalize(r.rel) }));
const base = `/api/v1${e.mount === 'BARE' ? '' : e.mount}`;
const segs = e.path.slice(base.length).split('/').filter(Boolean);
const relForErr = '/' + segs.join('/');
const hit = routes.find((r) => r.method === e.method && segMatch(r.rel, segs));
expect(hit || { tool_path: e.path, wanted: `${e.method} ${relForErr}`, declared: routes }).toBeDefined();
expect(hit).toBeDefined();
}
});
test('workflows history ordering: /history before /:workflowId/history', () => {
const routes = extractRoutes('routes/workflows.js');
const allIdx = routes.findIndex((r) => r.rel === '/history');
const idIdx = routes.findIndex((r) => r.rel === '/:workflowId/history');
expect(allIdx).toBeGreaterThan(-1);
expect(idIdx).toBeGreaterThan(allIdx);
});
test('no route file in the table double-prefixes its own mount', () => {
for (const e of EXPECTED) {
for (const r of extractRoutes(e.file)) {
const first = (r.rel || '/').split('/').filter(Boolean)[0];
const mountName = e.mount.replace('/', '');
if (e.mount !== 'BARE' && first && first === mountName) {
throw new Error(`${e.file} declares ${r.method} ${r.rel} — double prefix under ${e.mount}`);
}
}
}
});
});
describe('DC-166 r2: composition dispatch through the REAL workflows router', () => {
function buildApp() {
const app = express();
app.use(express.json());
const ah = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
const licenseStub = {
requirePremium: () => (req, res, next) => next(),
};
const ctx = {
workflowEngine: {
listWorkflows: () => [{ id: 'w1' }],
setWorkflowEnabled: (id, on) => ({ id, enabled: on }),
executeWorkflow: async (id) => ({ id, ran: true }),
getHistory: (id, limit) => [{ id: id || null, limit }],
},
licenseManager: licenseStub,
asyncHandler: ah,
ok: (res, data) => res.status(200).json({ success: true, data }),
};
app.use('/api/v1/workflows', require('../../routes/workflows')(ctx));
return app;
}
function dispatch(app, method, url) {
return new Promise((resolve) => {
const req = { method, url, headers: {} };
const res = {
statusCode: 200,
setHeader() {}, removeHeader() {}, on() {}, writeHead(c) { this.statusCode = c; return this; },
end(b) { resolve({ status: this.statusCode, body: b ? String(b) : '' }); },
json(b) { this.end(JSON.stringify(b)); },
status(c) { this.statusCode = c; return this; },
};
app._router.handle(req, res, () => resolve({ status: 404, body: '' }));
});
}
test('MCP-composed POST /api/v1/workflows/:id/run reaches the run handler (judge r1 defect — now fixed)', async () => {
const r = await dispatch(buildApp(), 'POST', '/api/v1/workflows/w1/run');
expect(r.status).toBe(200);
expect(r.body).toContain('"ran":true');
});
test('list and both history forms resolve with correct handlers and order', async () => {
const app = buildApp();
const list = await dispatch(app, 'GET', '/api/v1/workflows');
expect(list.body).toContain('"workflows":[');
const all = await dispatch(app, 'GET', '/api/v1/workflows/history');
expect(all.body).toContain('"limit":100');
const one = await dispatch(app, 'GET', '/api/v1/workflows/w1/history');
expect(one.body).toContain('"limit":50');
});
});
@@ -0,0 +1,199 @@
/**
* DC-166: MCP control-plane completion — 11 new tools (28 total).
* Proves: tool catalog integrity (names unique/aligned with handlers),
* each new handler hits the RIGHT method+path+body (captured fetchImpl),
* calendar days->ISO window translation, security summary fan-out,
* DNS delete query construction, and credential passthrough on writes.
*/
'use strict';
const { TOOLS, createToolHandler } = require('../../src/mcp/mcp-core');
function harness(handlerOpts = {}) {
const calls = [];
const fetchImpl = async (url, opts) => {
calls.push({ url, method: opts.method, headers: opts.headers, body: opts.body ? JSON.parse(opts.body) : undefined });
return { ok: true, status: 200, text: async () => JSON.stringify({ success: true, echo: calls[calls.length - 1] }) };
};
const handleTool = createToolHandler({
baseUrl: 'http://127.0.0.1:1',
apiKey: 'dk_' + 'test_key',
fetchImpl,
...handlerOpts,
});
return { calls, handleTool };
}
const NEW_TOOLS = [
'dashcaddy_calendar_events', 'dashcaddy_calendar_add_source',
'dashcaddy_security_events', 'dashcaddy_security_summary',
'dashcaddy_license_status', 'dashcaddy_capabilities',
'dashcaddy_dns_add_record', 'dashcaddy_dns_remove_record',
'dashcaddy_deploy_recipe', 'dashcaddy_run_workflow',
'dashcaddy_tailscale_status',
];
describe('DC-166 tool catalog', () => {
test('all 11 new tools declared, total 28, names unique', () => {
const names = TOOLS.map((t) => t.name);
for (const n of NEW_TOOLS) expect(names).toContain(n);
expect(names.length).toBe(28);
expect(new Set(names).size).toBe(28);
});
test('every tool has description + object inputSchema', () => {
for (const t of TOOLS) {
expect(typeof t.description).toBe('string');
expect(t.description.length).toBeGreaterThan(10);
expect(t.inputSchema.type).toBe('object');
}
});
test('required fields declared on write tools', () => {
const byName = Object.fromEntries(TOOLS.map((t) => [t.name, t]));
expect(byName.dashcaddy_dns_add_record.inputSchema.required).toEqual(['domain', 'ip']);
expect(byName.dashcaddy_deploy_recipe.inputSchema.required).toEqual(['recipeId']);
expect(byName.dashcaddy_run_workflow.inputSchema.required).toEqual(['workflowId']);
expect(byName.dashcaddy_calendar_add_source.inputSchema.required).toEqual(['name', 'type', 'url']);
expect(byName.dashcaddy_calendar_add_source.inputSchema.properties.type.enum).toEqual(['caldav', 'ics']);
});
});
describe('DC-166 handler routing (captured fetchImpl)', () => {
test('calendar_events translates days into ISO start/end window', async () => {
const { calls, handleTool } = harness();
const before = Date.now();
await handleTool('dashcaddy_calendar_events', { days: 7 });
expect(calls).toHaveLength(1);
expect(calls[0].method).toBe('GET');
const u = new URL(calls[0].url);
expect(u.pathname).toBe('/api/v1/calendar/events');
const start = new Date(u.searchParams.get('start'));
const end = new Date(u.searchParams.get('end'));
expect(Math.abs(start.getTime() - before)).toBeLessThan(60000);
// ~7-day window: allow 1s scheduling slack around exactly 7*86400s
expect(end - start).toBeGreaterThanOrEqual(7 * 86400e3 - 1000);
expect(end - start).toBeLessThanOrEqual(7 * 86400e3 + 60000);
});
test('calendar_events defaults to 14 days and clamps to 60', async () => {
const { calls, handleTool } = harness();
await handleTool('dashcaddy_calendar_events', {});
const u0 = new URL(calls[0].url);
const span0 = new Date(u0.searchParams.get('end')) - new Date(u0.searchParams.get('start'));
expect(Math.round(span0 / 86400e3)).toBe(14);
await handleTool('dashcaddy_calendar_events', { days: 999 });
const u = new URL(calls[1].url);
const span = new Date(u.searchParams.get('end')) - new Date(u.searchParams.get('start'));
expect(Math.round(span / 86400e3)).toBe(60);
});
test('calendar_add_source posts credentials through, omits absent fields', async () => {
const { calls, handleTool } = harness();
await handleTool('dashcaddy_calendar_add_source', { name: 'P', type: 'caldav', url: 'https://c.example/dav', user: 'u1', pass: 'p1' });
await handleTool('dashcaddy_calendar_add_source', { name: 'F', type: 'ics', url: 'https://f.example/a.ics' });
await handleTool('dashcaddy_calendar_add_source', { name: 'T', type: 'caldav', url: 'https://c2.example/dav', token: 'tok_123' });
expect(calls[0].method).toBe('POST');
expect(calls[0].url).toBe('http://127.0.0.1:1/api/v1/calendar/sources');
expect(calls[0].body).toEqual({ name: 'P', type: 'caldav', url: 'https://c.example/dav', user: 'u1', pass: 'p1' });
expect(calls[1].body.user).toBeUndefined();
expect(calls[1].body.pass).toBeUndefined();
// token auth variant is forwarded (route accepts user+pass OR token)
expect(calls[2].body).toEqual({ name: 'T', type: 'caldav', url: 'https://c2.example/dav', token: 'tok_123' });
});
test('security_events passes only provided filters as query string', async () => {
const { calls, handleTool } = harness();
await handleTool('dashcaddy_security_events', { limit: 5, severity: 'critical' });
await handleTool('dashcaddy_security_events', {});
const u1 = new URL(calls[0].url);
expect(u1.pathname).toBe('/api/v1/security/events');
expect(u1.searchParams.get('limit')).toBe('5');
expect(u1.searchParams.get('severity')).toBe('critical');
expect(u1.searchParams.get('source_type')).toBeNull();
expect(calls[1].url).toBe('http://127.0.0.1:1/api/v1/security/events');
});
test('security_summary fans out stats+perimeter, respects withPerimeter false', async () => {
const { calls, handleTool } = harness();
const r1 = await handleTool('dashcaddy_security_summary', {});
expect(calls.map(c => c.url)).toEqual([
'http://127.0.0.1:1/api/v1/security/events/stats',
'http://127.0.0.1:1/api/v1/security/events/perimeter',
]);
expect(r1.stats).toBeDefined();
expect(r1.perimeter).toBeDefined();
calls.length = 0;
const r2 = await handleTool('dashcaddy_security_summary', { withPerimeter: false });
expect(calls).toHaveLength(1);
expect(r2.success).toBe(true);
});
test('license_status and capabilities read the right endpoints', async () => {
const { calls, handleTool } = harness();
await handleTool('dashcaddy_license_status', {});
await handleTool('dashcaddy_capabilities', {});
expect(calls[0].url).toBe('http://127.0.0.1:1/api/v1/license/status');
expect(calls[0].method).toBe('GET');
expect(calls[1].url).toBe('http://127.0.0.1:1/api/v1/capabilities');
});
test('dns_add_record posts the provider schema body', async () => {
const { calls, handleTool } = harness();
await handleTool('dashcaddy_dns_add_record', { domain: 'x.home.example', ip: '10.0.0.5', ttl: 300 });
expect(calls[0].method).toBe('POST');
expect(calls[0].url).toBe('http://127.0.0.1:1/api/v1/dns/record');
expect(calls[0].body).toEqual({ domain: 'x.home.example', ip: '10.0.0.5', ttl: 300 });
await handleTool('dashcaddy_dns_add_record', { domain: 'y.home.example', ip: '10.0.0.6' });
expect(calls[1].body.ttl).toBeUndefined();
});
test('dns_remove_record deletes with encoded query, type defaults A', async () => {
const { calls, handleTool } = harness();
await handleTool('dashcaddy_dns_remove_record', { domain: 'a b.example', type: 'CNAME' });
await handleTool('dashcaddy_dns_remove_record', { domain: 'z.example', ipAddress: '1.2.3.4' });
expect(calls[0].method).toBe('DELETE');
const u0 = new URL(calls[0].url);
expect(u0.pathname).toBe('/api/v1/dns/record');
expect(u0.searchParams.get('domain')).toBe('a b.example');
expect(u0.searchParams.get('type')).toBe('CNAME');
const u1 = new URL(calls[1].url);
expect(u1.searchParams.get('type')).toBe('A');
expect(u1.searchParams.get('ipAddress')).toBe('1.2.3.4');
});
test('deploy_recipe maps overrides to config body', async () => {
const { calls, handleTool } = harness();
await handleTool('dashcaddy_deploy_recipe', { recipeId: 'media-stack', overrides: { plex: { port: 32400 } } });
expect(calls[0].method).toBe('POST');
expect(calls[0].url).toBe('http://127.0.0.1:1/api/v1/recipes/deploy');
expect(calls[0].body).toEqual({ recipeId: 'media-stack', config: { plex: { port: 32400 } } });
await handleTool('dashcaddy_deploy_recipe', { recipeId: 'x' });
expect(calls[1].body.config).toBeUndefined();
});
test('run_workflow posts encoded id to /run, tailscale_status reads status', async () => {
const { calls, handleTool } = harness();
await handleTool('dashcaddy_run_workflow', { workflowId: 'disk space alert' });
expect(calls[0].method).toBe('POST');
expect(decodeURIComponent(calls[0].url)).toBe('http://127.0.0.1:1/api/v1/workflows/disk space alert/run');
expect(calls[0].url).toContain('disk%20space%20alert');
await handleTool('dashcaddy_tailscale_status', {});
expect(calls[1].url).toBe('http://127.0.0.1:1/api/v1/tailscale/status');
expect(calls[1].method).toBe('GET');
});
test('credential passthrough preserved on the new write tools (x-api-key)', async () => {
const { calls, handleTool } = harness();
await handleTool('dashcaddy_dns_add_record', { domain: 'w.example', ip: '1.1.1.1' });
expect(calls[0].headers['x-api-key']).toBe('dk_' + 'test_key');
});
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/);
});
});
@@ -132,6 +132,7 @@ function readMountedRoutes() {
'routes/share.js', // apiRouter.use(shareRoutes({...})) // bare mount (DC-053)
'routes/services.js', // apiRouter.use(serviceRoutes({...})) // bare mount — needed for /api/v1/services + /api/v1/services/status PUBLIC_ROUTES
'routes/version.js', // apiRouter.use(versionRoute.buildRouter()) // bare mount — needed for /api/v1/version PUBLIC_ROUTES
'routes/capabilities.js', // apiRouter.use(capabilitiesRoute.buildRouter()) // bare mount — DC-165 /api/v1/capabilities PUBLIC_ROUTES
];
// Prefix map: explicit prefix from src/app.js's apiRouter.use() call
const prefixMap = {
+24
View File
@@ -218,6 +218,30 @@ paths:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/v1/capabilities:
get:
tags: [System]
summary: Get capability manifest
description: >-
Public, machine-readable ground truth of the running build (DC-165):
product version, app-template count and per-category breakdown, MCP
tool names and protocol revisions, premium feature list, and
supported-language count. The six measured fields (version, template
count/categories, tools, transport identity, premium list, language
count) are computed at request time from the same modules the product
uses at runtime; the remaining fields (product name, one-line
description, endpoint paths) are static registration/product
metadata. Intended as the ground-truth surface for AI assistants and
external docs so the MEASURED numbers cannot drift. Contains no
secrets, credentials, or host-specific data.
responses:
'200':
description: Successful operation (public route; no authentication — see PUBLIC_ROUTES)
content:
application/json:
schema:
$ref: '#/components/schemas/SuccessResponse'
/api/v1/csrf-token:
get:
tags: [Authentication]
+81
View File
@@ -0,0 +1,81 @@
'use strict';
/**
* DC-165: Public capability manifest — one machine-readable ground truth
* about what THIS running DashCaddy build actually does.
*
* Purpose (Sami 2026-10-09: "It should be LLM readable"): the marketing
* site, llms.txt, docs and the live product must never disagree. Static
* prose drifts (77 vs 78 templates proved it). This endpoint lets any AI
* assistant — or the site build itself — read the truth from the running
* product. The six MEASURED fields (version, template count + categories,
* MCP tool names, transport identity triple, premium list, language count)
* derive from the runtime modules the product itself uses (APP_TEMPLATES,
* mcp-core TOOLS, mcp.js MCP_RUNTIME, PREMIUM_FEATURES, i18n, version.js) —
* nothing measured is hand-typed, so those cannot drift BY CONSTRUCTION.
* `product`, `description`, `api.restMount` and `mcp.endpoint` are STATIC
* registration/product metadata (visible in app.js) — labeled as such in
* openapi.yaml. This is the
*
* Public by design (like /version and /health): no secrets, no usage data,
* no hostnames — only static capability facts of the installed build.
* Registered in PUBLIC_ROUTES (middleware.js, exact:true, GET) and guarded
* by public-routes-drift.test.js like every other public route.
*
* buildRouter() is attached as a property so the drift test's router-walk
* sees the route (routes/version.js convention).
*/
const express = require('express');
const { ok } = require('../src/utils/responses');
const versionRoute = require('./version');
function buildRouter(deps = {}) {
const router = express.Router();
const asyncHandler = deps.asyncHandler
|| ((fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next));
router.get('/capabilities', asyncHandler(async (req, res) => {
const { APP_TEMPLATES } = require('../src/docker/app-templates');
const templates = APP_TEMPLATES || {};
const templateIds = Object.keys(templates);
const categories = {};
for (const id of templateIds) {
const c = (templates[id] && templates[id].category) || 'uncategorized';
categories[c] = (categories[c] || 0) + 1;
}
const { TOOLS } = require('../src/mcp/mcp-core');
const { MCP_RUNTIME } = require('./mcp');
const tools = (TOOLS || []).map((t) => t.name).sort();
const { PREMIUM_FEATURES } = require('../src/managers/license-manager');
const premiumFeatures = Object.keys(PREMIUM_FEATURES || {}).sort();
const i18n = require('../src/utilities/i18n');
let languageCount = null;
try { languageCount = i18n.getSupportedLanguages().length; } catch (_) { /* optional surface */ }
ok(res, {
product: 'dashcaddy',
version: versionRoute.getVersion(),
description: 'Self-hosting dashboard: one-click Docker app deployment with automatic SSL, DNS and reverse proxy; AI intent router; MCP control plane.',
templates: { count: templateIds.length, categories },
mcp: {
transport: MCP_RUNTIME.transport,
protocolVersion: MCP_RUNTIME.protocolVersion,
protocolVersions: MCP_RUNTIME.supportedProtocolVersions,
endpoint: '/api/v1/mcp',
tools
},
premiumFeatures,
api: { restMount: '/api/v1' },
i18n: { languageCount }
});
}));
return router;
}
module.exports = { buildRouter };
module.exports.default = function factory(ctx) { return buildRouter(ctx); };
+19 -5
View File
@@ -63,8 +63,18 @@ const { ValidationError, ForbiddenError } = require('../src/utilities/errors');
const { ok } = require('../src/utils/responses');
const { createToolHandler, createMcpHandler, TOOLS } = require('../src/mcp/mcp-core');
const PROTOCOL_VERSION = '2025-03-10';
const SUPPORTED_PROTOCOL_VERSIONS = ['2025-03-10', '2024-11-05'];
// MCP_RUNTIME is the single source of truth for the transport identity.
// DC-165 rev2 (judge B0): the /info route AND the /api/v1/capabilities
// manifest both read these values instead of re-typing them, so a protocol
// bump in one place cannot leave the other stale. The capability test asserts
// the manifest equals these constants.
const MCP_RUNTIME = {
transport: 'streamable-http',
protocolVersion: '2025-03-10',
supportedProtocolVersions: ['2025-03-10', '2024-11-05'],
};
const PROTOCOL_VERSION = MCP_RUNTIME.protocolVersion;
const SUPPORTED_PROTOCOL_VERSIONS = MCP_RUNTIME.supportedProtocolVersions;
const SERVER_VERSION = (() => {
try {
return require(path.join(__dirname, '..', 'package.json')).version;
@@ -275,7 +285,7 @@ module.exports = function createMcpRoutes({ asyncHandler, errorResponse, log, au
router.get('/info', asyncHandler(async (req, res) => {
ok(res, {
endpoint: publicEndpoint(req),
transport: 'streamable-http',
transport: MCP_RUNTIME.transport,
protocolVersion: PROTOCOL_VERSION,
supportedProtocolVersions: SUPPORTED_PROTOCOL_VERSIONS,
serverVersion: SERVER_VERSION,
@@ -320,7 +330,7 @@ module.exports = function createMcpRoutes({ asyncHandler, errorResponse, log, au
// shapes they don't (e.g. ChatGPT hosted connectors need public https).
const httpConfig = {
url: endpoint,
transport: 'streamable-http',
transport: MCP_RUNTIME.transport,
headers: { 'x-api-key': keyData.key },
mcpProtocolVersion: PROTOCOL_VERSION,
};
@@ -336,7 +346,7 @@ module.exports = function createMcpRoutes({ asyncHandler, errorResponse, log, au
name: keyData.name,
scopes: keyData.scopes,
endpoint,
transport: 'streamable-http',
transport: MCP_RUNTIME.transport,
httpConfig,
note: clientNotes[client],
warning: 'Save this key securely — it will not be shown again. Revoke any time from Settings → Connect your AI.',
@@ -347,3 +357,7 @@ module.exports = function createMcpRoutes({ asyncHandler, errorResponse, log, au
return router;
};
// DC-165 rev2: expose transport identity so the capabilities manifest reads
// the SAME constants the live MCP route serves (single source of truth).
module.exports.MCP_RUNTIME = MCP_RUNTIME;
+22 -14
View File
@@ -32,15 +32,21 @@ module.exports = function({ workflowEngine, licenseManager, asyncHandler, ok })
router.use(licenseManager.requirePremium('workflows'));
// ===== WORKFLOW MANAGEMENT ENDPOINTS =====
// PATH CONTRACT: mounted at apiRouter.use('/workflows', ...) in src/app.js,
// so paths here are RELATIVE to that mount. The previous literal
// router.get('/workflows', ...) produced a double prefix
// (/api/v1/workflows/workflows/...) that the shipped dashboard bundle
// (status/js/bundled-workflows.js calls single-prefix /api/v1/workflows/)
// never hits; the DC-166 judge flagged the composed-path mismatch.
// List all bundled workflows
router.get('/workflows', asyncHandler(async (req, res) => {
// List bundled workflows
router.get('/', asyncHandler(async (req, res) => {
const workflows = workflowEngine.listWorkflows();
ok(res, { workflows });
}, 'workflows-list'));
// Enable a workflow
router.post('/workflows/:workflowId/enable', asyncHandler(async (req, res) => {
router.post('/:workflowId/enable', asyncHandler(async (req, res) => {
const { workflowId } = req.params;
validateWorkflowId(workflowId);
const result = workflowEngine.setWorkflowEnabled(workflowId, true);
@@ -48,7 +54,7 @@ module.exports = function({ workflowEngine, licenseManager, asyncHandler, ok })
}, 'workflows-enable'));
// Disable a workflow
router.post('/workflows/:workflowId/disable', asyncHandler(async (req, res) => {
router.post('/:workflowId/disable', asyncHandler(async (req, res) => {
const { workflowId } = req.params;
validateWorkflowId(workflowId);
const result = workflowEngine.setWorkflowEnabled(workflowId, false);
@@ -56,7 +62,7 @@ module.exports = function({ workflowEngine, licenseManager, asyncHandler, ok })
}, 'workflows-disable'));
// Manually trigger a workflow
router.post('/workflows/:workflowId/run', asyncHandler(async (req, res) => {
router.post('/:workflowId/run', asyncHandler(async (req, res) => {
const { workflowId } = req.params;
validateWorkflowId(workflowId);
const triggerData = req.body || {};
@@ -66,20 +72,22 @@ module.exports = function({ workflowEngine, licenseManager, asyncHandler, ok })
ok(res, { result });
}, 'workflows-run'));
// Get execution history for a workflow
router.get('/workflows/:workflowId/history', asyncHandler(async (req, res) => {
// ALL-history route declared BEFORE /:workflowId/history so the literal
// 'history' segment is not captured as a workflowId.
// Get all workflow execution history
router.get('/history', asyncHandler(async (req, res) => {
const limit = parseInt(req.query.limit) || 100;
const history = workflowEngine.getHistory(null, limit);
ok(res, { history });
}, 'workflows-all-history'));
// Get execution history for one workflow
router.get('/:workflowId/history', asyncHandler(async (req, res) => {
const { workflowId } = req.params;
const limit = parseInt(req.query.limit) || 50;
const history = workflowEngine.getHistory(workflowId, limit);
ok(res, { history });
}, 'workflows-history'));
// Get all workflow execution history
router.get('/workflows/history', asyncHandler(async (req, res) => {
const limit = parseInt(req.query.limit) || 100;
const history = workflowEngine.getHistory(null, limit);
ok(res, { history });
}, 'workflows-all-history'));
return router;
};
+11
View File
@@ -542,6 +542,13 @@ async function createApp() {
apiRouter.use(versionRouter);
log.info('app', `Version endpoint available at /api/v1/version (v${appVersion})`);
// DC-165: capability manifest — public, machine-readable ground truth of
// THIS build (template count, MCP tools, premium features). Same-shape
// registration as /version so public-routes-drift.test.js walks it.
const capabilitiesRoute = require('../routes/capabilities');
apiRouter.use(capabilitiesRoute.buildRouter());
log.info('app', 'Capability manifest available at /api/v1/capabilities');
// Wire up notification listeners for resourceMonitor and backupManager
if (ctx.notification && ctx.resourceMonitor) {
ctx.resourceMonitor.on('alert', (alertData) => {
@@ -693,6 +700,10 @@ async function createApp() {
}));
// DC-108: Multi-host fleet management
// DC-165: gate behind premium entitlement ('fleet' in PREMIUM_FEATURES).
// Mounted at the router level so EVERY future fleet sub-route inherits
// the gate by default (fail-closed for new endpoints).
apiRouter.use('/fleet', ctx.licenseManager.requirePremium('fleet'));
apiRouter.use(fleetRoutes({
log: ctx.log,
asyncHandler: ctx.asyncHandler,
+57 -1
View File
@@ -24,7 +24,25 @@ const LICENSE_SERVER_URL = process.env.LICENSE_SERVER_URL || null; // Set when l
const PREMIUM_FEATURES = {
sso: { name: 'Auto-Login SSO', description: 'Automatic single sign-on for deployed apps' },
recipes: { name: 'Recipes', description: 'Multi-container stack deployment' },
swarm: { name: 'Docker Swarm', description: 'Multi-node cluster orchestration' }
swarm: { name: 'Docker Swarm', description: 'Multi-node cluster orchestration' },
// DC-165: site has always sold Fleet as Premium (pricing page + comparison
// table); the gate was missing. hasFeature() falls back to
// Object.keys(PREMIUM_FEATURES) for activations that stored no explicit
// features list, so existing premium licenses (incl. offline activations
// like the creator lifetime key) gain fleet automatically on next load.
// 'fleet' is now part of the shipped entitlement contract — a new
// PREMIUM_FEATURES key must ALWAYS be accompanied by the license-server
// grant list (plans.js) or online activations will 403 it.
fleet: { name: 'Multi-Host Fleet Management', description: 'Register remote DashCaddy instances, health probes, and multi-host deploys' },
// DC-165: these two have ALWAYS been requirePremium-gated (routes/
// workflows.js line 32, routes/backups.js schedule routes) but were
// never listed here — offline activations could therefore never gain
// them, and the marketing site didn't advertise them either. The site
// now advertises both (Sami's decision 2026-10-09: "advertise as
// Premium"); listing them here closes the offline path and makes
// PREMIUM_FEATURES the single source of truth for gated features.
workflows: { name: 'Workflow Automation', description: 'Bundled one-click maintenance workflows — update checks, disk cleanup, cert expiry checks — enable, run, and review history' },
'auto-backup': { name: 'Scheduled Backups', description: 'Automated per-app backup schedules (manual one-click backup stays free)' }
};
class LicenseManager {
@@ -76,6 +94,9 @@ class LicenseManager {
expiredAt: this.activation.expiresAt
});
} else {
// DC-165 rev2 (judge P0): offline feature-snapshot repair, in a
// helper to keep load() complexity/nesting inside house lint.
await this._migrateOfflineFeatureSnapshot();
this.log.info?.('license', 'License loaded', {
code: this._maskCode(this.activation.code),
expiresAt: this.activation.expiresAt,
@@ -509,6 +530,41 @@ class LicenseManager {
return true;
}
/**
* DC-165 rev2 (judge P0): repair an OFFLINE activation's feature snapshot
* when it was stored by an older build whose PREMIUM_FEATURES lacked a
* key this build gates (fleet, workflows, auto-backup). Without this the
* offline snapshot would deny the feature forever despite an active
* premium license. Refresh is an APPEND-ONLY union: current premium keys
* first, then any unknown legacy entries preserved verbatim — a
* missing-key repair never implies removal. Result is identical to what
* re-activating the same code today would store (activate() writes
* Object.keys(PREMIUM_FEATURES)). ONLINE activations are NOT touched:
* their authoritative feature list comes from the license server on
* validation; this only reconciles a local build's own derived snapshot
* with its own constant. Store failure warns — in-memory state stays
* correct for this process.
*/
async _migrateOfflineFeatureSnapshot() {
if (!this.activation || this.activation.validationMethod !== 'offline') return;
if (!Array.isArray(this.activation.features)) return;
const current = Object.keys(PREMIUM_FEATURES);
const stored = new Set(this.activation.features);
const missing = current.filter((f) => !stored.has(f));
if (!missing.length) return;
const extras = this.activation.features.filter((f) => !current.includes(f));
this.activation.features = current.concat(extras);
try {
await this.credentialManager.store(LICENSE_CRED_KEY, JSON.stringify(this.activation), {
activatedAt: this.activation.activatedAt,
expiresAt: this.activation.expiresAt
});
} catch (error) {
this.log.warn?.('license', 'Could not persist premium-feature migration (in-memory state still correct)', { error: error.message });
}
this.log.info?.('license', 'Migrated offline activation to current premium feature set', { added: missing });
}
async _validateLoadedActivationForServerMode() {
if (!LICENSE_SERVER_URL || !this.activation) return true;
const cachedWasOnline = this.activation.validationMethod === 'online';
+196
View File
@@ -192,6 +192,123 @@ const TOOLS = [
description: 'List all hosts in the DashCaddy fleet (for multi-server management).',
inputSchema: { type: 'object', properties: {} },
},
// ── Calendar (DC-166) ──
{
name: 'dashcaddy_calendar_events',
description: 'List upcoming calendar events from configured sources (shared with the dashboard calendar widget). Useful for "what is on my schedule this week?"',
inputSchema: {
type: 'object',
properties: {
days: { type: 'number', description: 'Horizon in days from now (default 14, max 60)' },
},
},
},
{
name: 'dashcaddy_calendar_add_source',
description: 'Add a calendar source: a CalDAV URL with user+pass or a token, or a read-only ICS feed URL. DashCaddy-deployed Radicale servers auto-register themselves; this tool is for external calendars.',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: 'Display name (e.g. "Personal")' },
type: { type: 'string', enum: ['caldav', 'ics'], description: 'Source type' },
url: { type: 'string', description: 'CalDAV base URL or .ics feed URL (https required)' },
user: { type: 'string', description: 'CalDAV username (caldav with user+pass auth)' },
pass: { type: 'string', description: 'CalDAV password (caldav with user+pass auth)' },
token: { type: 'string', description: 'Auth token (CalDAV token auth or secret-bearing ICS URL)' },
},
required: ['name', 'type', 'url'],
},
},
// ── Security Center (DC-166) ──
{
name: 'dashcaddy_security_events',
description: 'Query the unified Security Center event pipeline (docker/caddy/dns/tailscale/audit/health sources, PII-masked). Useful for "any suspicious activity?" or "recent auth failures".',
inputSchema: {
type: 'object',
properties: {
limit: { type: 'number', description: 'Max events (default 20)' },
source_type: { type: 'string', description: 'Filter by source (caddy, docker, dns, tailscale, audit, health…)' },
severity: { type: 'string', description: 'Filter by severity (info, warning, error, critical)' },
since: { type: 'string', description: 'ISO datetime lower bound (optional)' },
},
},
},
{
name: 'dashcaddy_security_summary',
description: 'Security Center aggregate stats plus per-IP/per-vhost perimeter analytics. Useful for "which IPs are attacking me?"',
inputSchema: {
type: 'object',
properties: {
withPerimeter: { type: 'boolean', description: 'Include perimeter rollups (default true)' },
},
},
},
// ── License + capabilities (DC-166) ──
{
name: 'dashcaddy_license_status',
description: 'License status: active/inactive, tier, lifetime flag, days remaining, granted premium features, and the gated-feature catalog.',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'dashcaddy_capabilities',
description: 'Machine-readable capability manifest of the running build: version, app-template count + categories, MCP tool list, premium feature list, language count. Ground truth for "what can this DashCaddy do?"',
inputSchema: { type: 'object', properties: {} },
},
// ── 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.',
inputSchema: {
type: 'object',
properties: {
domain: { type: 'string', description: 'FQDN or hostname to add (e.g. "printer.home.example.com")' },
ip: { type: 'string', description: 'IPv4 address for the A record' },
ttl: { type: 'number', description: 'TTL seconds (optional, provider default)' },
},
required: ['domain', 'ip'],
},
},
{
name: 'dashcaddy_dns_remove_record',
description: 'Delete a DNS record via the configured provider. Pass the identifying fields that dashcaddy_list_dns returned (domain, type, ipAddress).',
inputSchema: {
type: 'object',
properties: {
domain: { type: 'string', description: 'Record name (e.g. "printer.home.example.com")' },
type: { type: 'string', description: 'Record type (A, CNAME…; default A)' },
ipAddress: { type: 'string', description: 'Record data (IP) to disambiguate' },
},
required: ['domain'],
},
},
// ── Recipes & workflows (DC-166) ──
{
name: 'dashcaddy_deploy_recipe',
description: 'Deploy a multi-container Recipe stack (premium) by recipe id with optional per-service overrides. List ids via dashcaddy_search_catalog-style GET /api/v1/recipes/templates.',
inputSchema: {
type: 'object',
properties: {
recipeId: { type: 'string', description: 'Recipe id (e.g. "media-stack")' },
overrides: { type: 'object', description: 'Per-service config overrides (optional)', additionalProperties: true },
},
required: ['recipeId'],
},
},
{
name: 'dashcaddy_run_workflow',
description: 'Run a bundled maintenance workflow (premium): auto-restart-on-crash, backup-before-update, health-check-on-interval, disk-space-alert, weekly-container-report.',
inputSchema: {
type: 'object',
properties: { workflowId: { type: 'string', description: 'Workflow id' } },
required: ['workflowId'],
},
},
// ── Tailscale (DC-166) ──
{
name: 'dashcaddy_tailscale_status',
description: 'Tailscale integration status and enrolled devices (when the Tailscale feature is configured). Useful for checking VPN coverage of tailnet-only services.',
inputSchema: { type: 'object', properties: {} },
},
];
const RESOURCES = [
@@ -424,6 +541,85 @@ function createToolHandler({ baseUrl, apiKey, headers, fetchImpl, log }) {
return apiCall('GET', '/fleet/hosts');
}
// ── Calendar (DC-166) ──
case 'dashcaddy_calendar_events': {
// Route contract (routes/calendar.js): ISO start/end, capped 400-day
// range. Translate the friendlier `days` horizon into that shape.
const days = Math.min(Math.max(Number(args.days) || 14, 1), 60);
const from = new Date();
const to = new Date(from.getTime() + days * 86400000);
const p = `/calendar/events?start=${encodeURIComponent(from.toISOString())}&end=${encodeURIComponent(to.toISOString())}`;
return apiCall('GET', p);
}
case 'dashcaddy_calendar_add_source': {
return apiCall('POST', '/calendar/sources', {
name: args.name, type: args.type, url: args.url,
...(args.user ? { user: args.user } : {}),
...(args.pass ? { pass: args.pass } : {}),
...(args.token ? { token: args.token } : {}),
});
}
// ── Security Center (DC-166) ──
case 'dashcaddy_security_events': {
const qs = new URLSearchParams();
for (const k of ['limit', 'source_type', 'severity', 'since']) {
if (args[k] !== undefined && args[k] !== null) qs.set(k, String(args[k]));
}
const q = qs.toString();
return apiCall('GET', `/security/events${q ? `?${q}` : ''}`);
}
case 'dashcaddy_security_summary': {
const stats = await apiCall('GET', '/security/events/stats');
if (args.withPerimeter === false) return stats;
const peri = await apiCall('GET', '/security/events/perimeter');
return { stats, perimeter: peri };
}
// ── License + capabilities (DC-166) ──
case 'dashcaddy_license_status': {
return apiCall('GET', '/license/status');
}
case 'dashcaddy_capabilities': {
return apiCall('GET', '/capabilities');
}
// ── DNS write (DC-166) ──
case 'dashcaddy_dns_add_record': {
return apiCall('POST', '/dns/record', {
domain: args.domain, ip: args.ip,
...(args.ttl !== undefined ? { ttl: args.ttl } : {}),
});
}
case 'dashcaddy_dns_remove_record': {
const qs = new URLSearchParams();
qs.set('domain', String(args.domain));
qs.set('type', String(args.type || 'A'));
if (args.ipAddress !== undefined) qs.set('ipAddress', String(args.ipAddress));
return apiCall('DELETE', `/dns/record?${qs.toString()}`);
}
// ── Recipes & workflows (DC-166) ──
case 'dashcaddy_deploy_recipe': {
return apiCall('POST', '/recipes/deploy', {
recipeId: args.recipeId,
...(args.overrides ? { config: args.overrides } : {}),
});
}
case 'dashcaddy_run_workflow': {
return apiCall('POST', `/workflows/${encodeURIComponent(args.workflowId)}/run`, {});
}
// ── Tailscale (DC-166) ──
case 'dashcaddy_tailscale_status': {
return apiCall('GET', '/tailscale/status');
}
default:
return { error: true, message: `Unknown tool: ${name}` };
}
@@ -498,6 +498,7 @@ module.exports = function configureMiddleware(app, {
{ path: '/api/v1/monitoring/stats', exact: true, method: 'GET', monitoring: true },
{ path: '/api/v1/health-checks/status', exact: true, method: 'GET', monitoring: true },
{ path: '/api/v1/version', exact: true, method: 'GET' },
{ path: '/api/v1/capabilities', exact: true, method: 'GET' },
// Security ingest endpoints — auth via per-host Bearer token (handled in routes/security.js),
// NOT via TOTP/JWT. This lets remote DashCaddy agents and log parsers push events
// without needing a TOTP session.