From d478268de6b078c86d0351a71344980b422a8b46 Mon Sep 17 00:00:00 2001 From: Hermes Date: Fri, 9 Oct 2026 01:24:37 -0700 Subject: [PATCH] =?UTF-8?q?[pending-judge]=20feat(DC-165):=20product/site?= =?UTF-8?q?=20uniformity=20=E2=80=94=20fleet=20gate,=20complete=20premium?= =?UTF-8?q?=20list,=20LLM-readable=20capabilities=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Site-vs-product audit (2026-10-09, owner decision: "advertise as Premium") reconciles the entitlement triangle: marketing copy <-> PREMIUM_FEATURES <-> license-server grant list. - Fleet: the pricing page has always sold Multi-Host Fleet Management as Premium; code never gated it. Added fleet to PREMIUM_FEATURES and mounted requirePremium('fleet') at the ROUTER level (app.js) so every current AND future /fleet/* sub-route inherits the gate (fail-closed for new endpoints). License-server side: plans.js grants fleet for new activations; existing premium license records backfilled (ops, DNS-license-server). - Workflows + auto-backup: these were requirePremium-gated in routes since forever but absent from PREMIUM_FEATURES — the offline path could never grant them and the site never advertised them. Listed them (descriptions taken from bundled-workflows.js + backups/schedule schema fields) so the single source of truth now covers every gated feature; offline migration (below) grants them to existing activations; server grant list updated. - Offline activation forward-migration: load() now refreshes an OFFLINE activation's features snapshot to the current PREMIUM_FEATURES set when entries are missing (exactly what re-activating the same code today would store). ONLINE activations are never touched — the license server stays authoritative across the network boundary; this is the creator's local build-to-build coherence rule, not an entitlement widening over an external service. Persisted via credential store; in-memory state is correct even if persistence fails (warn, not fail). - GET /api/v1/capabilities (public, like /version): machine-readable ground truth of the RUNNING build — version, template count + categories, MCP tool names, protocol revisions, premium features, language count. Every number derived from the same modules the product uses (no drift by construction). Registered in PUBLIC_ROUTES (exact:true, GET), added to public-routes-drift.test.js directMounts, guarded like /version. No secrets, no host data (tested). - 8 new executed tests: fleet 403-on-free / pass-on-premium through the REAL requirePremium middleware (duck-typed LicenseManager instance, prototype preserved), hasFeature fallback, offline migration (persisted + in-memory), online NOT migrated (valid bounded cache stub incl. lastOnlineValidatedAt), capabilities payload == module-derived values (78 templates/17 cats/31 langs/fleet present), no-secrets scan of the payload. Tests: dc165 8/8; license-manager 76; tier-enforcement 21; public-routes- drift + composition-root unchanged green (119 combined on the targeted run). --- .../__tests__/dc165-uniformity.test.js | 176 ++++++++++++++++++ .../__tests__/public-routes-drift.test.js | 1 + dashcaddy-api/openapi.yaml | 26 +++ dashcaddy-api/routes/capabilities.js | 75 ++++++++ dashcaddy-api/src/app.js | 11 ++ dashcaddy-api/src/managers/license-manager.js | 45 ++++- dashcaddy-api/src/utilities/middleware.js | 1 + 7 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 dashcaddy-api/__tests__/dc165-uniformity.test.js create mode 100644 dashcaddy-api/routes/capabilities.js diff --git a/dashcaddy-api/__tests__/dc165-uniformity.test.js b/dashcaddy-api/__tests__/dc165-uniformity.test.js new file mode 100644 index 0000000..701c83f --- /dev/null +++ b/dashcaddy-api/__tests__/dc165-uniformity.test.js @@ -0,0 +1,176 @@ +/** + * 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('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); + 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); + }); +}); diff --git a/dashcaddy-api/__tests__/public-routes-drift.test.js b/dashcaddy-api/__tests__/public-routes-drift.test.js index ea085f9..a382bc1 100644 --- a/dashcaddy-api/__tests__/public-routes-drift.test.js +++ b/dashcaddy-api/__tests__/public-routes-drift.test.js @@ -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 = { diff --git a/dashcaddy-api/openapi.yaml b/dashcaddy-api/openapi.yaml index aff7750..00339a2 100644 --- a/dashcaddy-api/openapi.yaml +++ b/dashcaddy-api/openapi.yaml @@ -218,6 +218,32 @@ 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. Every value is derived from the same source + modules the product uses at runtime — intended as the single source of + truth for AI assistants and external docs so static prose cannot + drift. Contains no secrets, credentials, or host-specific data. + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + '401': + description: Unauthorized - authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/v1/csrf-token: get: tags: [Authentication] diff --git a/dashcaddy-api/routes/capabilities.js b/dashcaddy-api/routes/capabilities.js new file mode 100644 index 0000000..c0e2e77 --- /dev/null +++ b/dashcaddy-api/routes/capabilities.js @@ -0,0 +1,75 @@ +'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: exact version, template count + categories, MCP tool list, + * premium feature list, protocol revisions. Every number is derived from + * the same modules the product uses at runtime; nothing is hand-typed, so + * it cannot drift by construction. + * + * 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 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: 'streamable-http', + protocolVersions: ['2025-03-10', '2024-11-05'], + 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); }; diff --git a/dashcaddy-api/src/app.js b/dashcaddy-api/src/app.js index d66f110..1b2ab68 100644 --- a/dashcaddy-api/src/app.js +++ b/dashcaddy-api/src/app.js @@ -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, diff --git a/dashcaddy-api/src/managers/license-manager.js b/dashcaddy-api/src/managers/license-manager.js index 139670c..569da50 100644 --- a/dashcaddy-api/src/managers/license-manager.js +++ b/dashcaddy-api/src/managers/license-manager.js @@ -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,31 @@ class LicenseManager { expiredAt: this.activation.expiresAt }); } else { + // DC-165 forward-migration for OFFLINE activations: activate() + // stores features = Object.keys(PREMIUM_FEATURES) at activation + // time (line ~294), so a lifetime/creator activation minted + // before a feature was added (e.g. 'fleet') would lack it and + // requirePremium() 403s despite an active premium license. + // Refresh the snapshot to the CURRENT premium set — identical to + // what the same code would store if re-activated today. Online + // activations are NOT touched here: their authoritative feature + // list comes from the license server on validation. + if (this.activation.validationMethod === 'offline' && Array.isArray(this.activation.features)) { + const stored = new Set(this.activation.features); + const missing = Object.keys(PREMIUM_FEATURES).filter((f) => !stored.has(f)); + if (missing.length) { + this.activation.features = Object.keys(PREMIUM_FEATURES); + 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 }); + } + } this.log.info?.('license', 'License loaded', { code: this._maskCode(this.activation.code), expiresAt: this.activation.expiresAt, diff --git a/dashcaddy-api/src/utilities/middleware.js b/dashcaddy-api/src/utilities/middleware.js index 104282c..08f66fc 100644 --- a/dashcaddy-api/src/utilities/middleware.js +++ b/dashcaddy-api/src/utilities/middleware.js @@ -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.