diff --git a/BACKLOG.md b/BACKLOG.md index b6cdc56..beb2222 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -117,6 +117,12 @@ --- +### DC-017: Regression tests for depth-2 route paths + PUBLIC_ROUTES drift +- **status:** done +- **owner:** krystie +- **details:** After DC-005 path-fix (commit c39c80b) shipped 67 broken-require repairs across 21 depth-2 route files, two test gaps remained: (1) no test imported any depth-2 route module, so future refactors could reintroduce class A/B/C broken paths undetected; (2) no test verified that PUBLIC_ROUTES entries (in src/utilities/middleware.js) all correspond to actually-mounted routes — exactly the kind of drift DC-012 added a regression check for (probe paths), but only for the 5 probes. The full ~27-entry PUBLIC_ROUTES list could silently go stale. +- **result:** Added 3 files, fixed 1 test helper, no production code changed. New: `__tests__/depth2-routes-smoke.test.js` discovers every .js in routes/{apps,arr,auth,config,recipes}/ and asserts (a) the module loads without MODULE_NOT_FOUND, (b) it exports a factory function, (c) the factory runs without throwing when given universal deps; plus 3 source-of-truth scans that fail if any depth-2 route re-introduces class A (`../../../src/...`), class B (`../src/...`), or class C (`utilities/responses` instead of `utils/responses`) require paths. New: `__tests__/public-routes-drift.test.js` walks every aggregator + direct-mount router via Express stack introspection and asserts (a) every PUBLIC_ROUTES entry matches an actually-mounted route, (b) every CSRF excludedPath is publicly accessible, (c) all 5 probe paths are CSRF-exempt, (d) all 5 probe paths are excluded from request logging, (e) all 5 probe paths bypass Tailscale auth. New: `__tests__/test-helpers/universal-deps.js` — a Proxy + seed-object shared by both suites that returns sensible stubs (logger-shaped object, asyncHandler pass-through, path-string stubs for `path.dirname()` calls) for any property access; supports Object.assign/spread via ownKeys+getOwnPropertyDescriptor traps so aggregator factories that copy ctx into subCtx don't lose proxy magic. Fix to the test helper: (a) `log` is now a logger-shaped object (`{error, warn, info, debug, audit}` as noops) not a bare noopFn — fixes `(ctx.log || console).error(...)` in routes/apps/index.js; (b) `asyncHandler` seeded as own enumerable property — survives Object.assign({}, ctx, { helpers }); (c) added `SERVICES_FILE`, `CONFIG_FILE`, `TOTP_CONFIG_FILE`, `TAILSCALE_CONFIG_FILE`, `NOTIFICATIONS_FILE`, `loadSiteConfig`, `loadNotificationConfig`, `configStateManager`, `readConfig`, `saveConfig`, `helpers`, `safeErrorMessage` as own-enumerable seeds so aggregator sub-mounts destructure cleanly. Fix to public-routes-drift: aggregator walks use prefix `/api/v1` (matches src/app.js's bare-mount on apiRouter at /api/v1), direct-mount walks use `/api/v1` + explicit prefixMap entry. Added `routes/themes.js` and `routes/license.js` to directMounts (themes bare-mounted, license on `/license`). Result: **35 suites, 1036 tests, all passing** (was 1030 passing + 6 failing before this commit). The 6 failures were depth-2 factory errors + 22 PUBLIC_ROUTES stale entries that the test infrastructure was silently swallowing. + ## Coordination Rules 1. **Always `git pull` before starting work.** diff --git a/dashcaddy-api/__tests__/depth2-routes-smoke.test.js b/dashcaddy-api/__tests__/depth2-routes-smoke.test.js new file mode 100644 index 0000000..1692489 --- /dev/null +++ b/dashcaddy-api/__tests__/depth2-routes-smoke.test.js @@ -0,0 +1,110 @@ +/** + * Depth-2 route smoke-import tests + * + * Locks in the DC-005 path fix (commit c39c80b) so future refactors can't + * reintroduce broken require() paths in depth-2 route files. + * + * Background: + * - The DC-005 src/ refactor moved route files into depth-2 subdirectories + * (routes/auth/, routes/recipes/, routes/apps/, routes/arr/, routes/config/). + * - The path-rewrite script left 67 broken require() paths across 21 files: + * class A: '../../../src/...' (3 levels, goes above package root) + * class B: '../src/utils/...' (1 level, resolves to nonexistent routes/src/) + * class C: routes/apps/restore.js used 'utilities/responses' instead of 'utils/responses' + * - The bug shipped because NO TEST imported any depth-2 route file. Only + * depth-1 routes were tested. + * + * These tests do not exercise the routes' handler logic — that would require + * building full app contexts per route family. They only verify: + * 1. The module can be loaded without a MODULE_NOT_FOUND error. + * 2. It exports a callable factory function (module.exports = function(deps){...}). + * 3. The factory runs without throwing when given the minimum required deps. + * + * That alone catches ~80% of the DC-005 class: any require() with a wrong path + * blows up at module load time, before the factory is even called. Path bugs + * that only manifest at handler invocation time (e.g. require of a dep only + * used inside a handler body) won't be caught — but those are rare. + */ +const fs = require('fs'); +const path = require('path'); +const { universalDeps } = require('./test-helpers/universal-deps'); + +const PKG_ROOT = path.join(__dirname, '..'); +const DEPTH2_DIRS = ['apps', 'arr', 'auth', 'config', 'recipes']; + +function discoverDepth2Routes() { + const out = []; + for (const sub of DEPTH2_DIRS) { + const dir = path.join(PKG_ROOT, 'routes', sub); + if (!fs.existsSync(dir)) continue; + for (const f of fs.readdirSync(dir).filter(x => x.endsWith('.js'))) { + out.push(path.join('routes', sub, f)); + } + } + return out.sort(); +} + +describe('Depth-2 Route Smoke Imports (locks in DC-005 path fix)', () => { + const routes = discoverDepth2Routes(); + // routes/auth/totp.js was already fixed in the DC-006 commit (one of the + // 21 files in the DC-005 fix batch). It was the first to be detected because + // DC-006 added tests that imported it. Every other route in this list has + // historically had ZERO test coverage — that's the gap this test closes. + + describe.each(routes)('module %s', (relPath) => { + test('loads without MODULE_NOT_FOUND (catches DC-005 class A/B/C paths)', () => { + // If any require() in this file uses '../../../src/...' (class A) or + // '../src/utils/...' (class B) or wrong directory name (class C), + // this require() throws and the test fails. + expect(() => require(path.join(PKG_ROOT, relPath))).not.toThrow(); + }); + + test('exports a factory function (module.exports = function(deps){...})', () => { + const factory = require(path.join(PKG_ROOT, relPath)); + expect(typeof factory).toBe('function'); + }); + + test('factory runs without throwing given minimal deps', () => { + const factory = require(path.join(PKG_ROOT, relPath)); + // universalDeps is a Proxy that returns no-op functions for any + // property access. So both patterns work: + // function({ a, b, c }) { ... } // picks a, b, c from universalDeps + // function(ctx) { ctx.licenseManager.requirePremium(...) } // works + // Any factory destructure is satisfied. Any method call returns undefined + // (callable no-op), so handler-invocation paths also don't crash here. + // We are ONLY catching module-load failures and factory-call-time + // failures — not handler-invocation behaviour. + expect(() => factory(universalDeps)).not.toThrow(); + }); + }); + + describe('Source-of-truth: no broken paths introduced', () => { + test('no depth-2 route uses ../../../src/ (class A)', () => { + const offenders = []; + for (const relPath of routes) { + const content = fs.readFileSync(path.join(PKG_ROOT, relPath), 'utf8'); + if (content.match(/require\(['"]\.\.\/\.\.\/\.\.\/src/)) offenders.push(relPath); + } + expect(offenders).toEqual([]); + }); + + test('no depth-2 route uses ../src/ (class B — would resolve to routes/src/)', () => { + const offenders = []; + for (const relPath of routes) { + const content = fs.readFileSync(path.join(PKG_ROOT, relPath), 'utf8'); + // Match '../src/' NOT preceded by another '/' (which would be class A) + if (content.match(/require\(['"]\.\.\/src\//)) offenders.push(relPath); + } + expect(offenders).toEqual([]); + }); + + test('no depth-2 route uses src/utilities/responses (class C — module lives at src/utils/responses)', () => { + const offenders = []; + for (const relPath of routes) { + const content = fs.readFileSync(path.join(PKG_ROOT, relPath), 'utf8'); + if (content.match(/['"]\.\.\/\.\.\/src\/utilities\/responses['"]/)) offenders.push(relPath); + } + expect(offenders).toEqual([]); + }); + }); +}); diff --git a/dashcaddy-api/__tests__/public-routes-drift.test.js b/dashcaddy-api/__tests__/public-routes-drift.test.js new file mode 100644 index 0000000..f533ee7 --- /dev/null +++ b/dashcaddy-api/__tests__/public-routes-drift.test.js @@ -0,0 +1,315 @@ +/** + * Public-routes allowlist drift tests + * + * Three allowlists in the DashCaddy codebase grant "no auth" or "no CSRF" + * access to specific paths. They MUST stay in sync — if a path is in + * PUBLIC_ROUTES but NOT in csrf excludedPaths (for a POST), the request gets + * a 403. If a path is in csrf excludedPaths but NOT in PUBLIC_ROUTES, it gets + * a 401. Both bugs are silent and ship-blocking for fresh users. + * + * Three lists: + * 1. PUBLIC_ROUTES — in src/utilities/middleware.js, used by auth middleware + * 2. excludedPaths — in src/security/csrf-protection.js, used by CSRF middleware + * 3. Request-logging skip list — in src/utilities/middleware.js, used by request logger + * 4. Tailscale auth bypass — in src/utilities/middleware.js, used by Tailscale gate + * + * Tests assert: + * A. No stale entries in any allowlist (path not in source-of-truth route mounts) + * B. The CSRF excludedPaths list is a subset of PUBLIC_ROUTES (any CSRF-exempt + * path must be publicly accessible) + * C. Probe paths appear in all three lists (liveness/readiness probes must + * bypass auth, CSRF, AND request logging) + * + * Source of truth for which paths are mounted: + * - src/app.js (inline apiRouter.get/post routes) + * - routes/[subdir]/[file].js (router.get/post/put/delete calls) + * + * The sync regex is conservative — matches quoted paths in mounted-route calls. + * False positives (e.g. comments containing route-like strings) are filtered + * by requiring the path to also be a real file in the routes/ tree OR appear + * inside an `apiRouter.` / `app.` call expression. + */ +const fs = require('fs'); +const path = require('path'); +const { universalDeps } = require('./test-helpers/universal-deps'); + +const PKG_ROOT = path.join(__dirname, '..'); +const SRC_APP = path.join(PKG_ROOT, 'src', 'app.js'); +const SRC_MIDDLEWARE = path.join(PKG_ROOT, 'src', 'utilities', 'middleware.js'); +const SRC_CSRF = path.join(PKG_ROOT, 'src', 'security', 'csrf-protection.js'); + +// Extract PUBLIC_ROUTES path strings from middleware.js +function readPublicRoutes() { + const content = fs.readFileSync(SRC_MIDDLEWARE, 'utf8'); + // Match `path: '/...'` + const matches = [...content.matchAll(/path:\s*['"]([^'"]+)['"]/g)].map(m => m[1]); + return new Set(matches); +} + +// Extract excludedPaths from csrf-protection.js +function readCsrfExcluded() { + const content = fs.readFileSync(SRC_CSRF, 'utf8'); + // Match string literals in arrays inside excludedPaths + const blockMatch = content.match(/excludedPaths\s*=\s*\[([^\]]+)\]/); + if (!blockMatch) return new Set(); + const entries = [...blockMatch[1].matchAll(/['"]([^'"]+)['"]/g)].map(m => m[1]); + return new Set(entries); +} + +// Extract all mounted-route paths from the live Express routers. +// +// Strategy: +// 1. Build a real Express app with stub middleware that just calls next() +// 2. Mount each aggregator router (auth/index.js, apps/index.js, arr/index.js) +// using universal deps +// 3. Use express.Router.stack to enumerate every registered route + path +// 4. Also inline-mount non-aggregator route files (e.g. routes/services.js) +// 5. For src/app.js inline routes (apiRouter.get('/health', ...)), parse directly +// +// This is more robust than regex — it captures routes registered via +// router.use(subRouter) chains inside aggregator files (e.g. auth/index.js +// calling router.use(initTotp(deps))). Regex can't see through that. +function readMountedRoutes() { + const mounted = new Set(); + + // ----- 1. Aggregator files ----- + const aggregators = ['routes/auth/index.js', 'routes/arr/index.js', 'routes/apps/index.js']; + for (const relPath of aggregators) { + const fullPath = path.join(PKG_ROOT, relPath); + if (!fs.existsSync(fullPath)) continue; + let factory; + try { + factory = require(fullPath); + } catch (e) { + // Some aggregators may not load with stub deps — skip them. + // The depth-2 smoke test catches module-load failures separately. + continue; + } + if (typeof factory !== 'function') continue; + let router; + try { + router = factory(universalDeps); + } catch (e) { + continue; + } + // Aggregators (auth/index, arr/index, apps/index) are mounted bare on + // apiRouter (which lives at /api/v1), so their inner routes inherit the + // /api/v1 prefix in production. Walk with that prefix so PUBLIC_ROUTES + // entries like '/api/v1/totp/config' match what the router actually + // serves in production. + walkRouter(router, '/api/v1', mounted); + } + + // ----- 2. Non-aggregator route files (mounted directly via apiRouter.use(...)) ----- + const directMounts = [ + 'routes/dns.js', // apiRouter.use('/dns', dnsRoutes({...})) + 'routes/notifications.js', // apiRouter.use('/notifications', notificationRoutes({...})) + 'routes/containers.js', // apiRouter.use('/containers', containerRoutes({...})) + 'routes/services.js', // apiRouter.use(serviceRoutes({...})) // bare mount + 'routes/health.js', // apiRouter.use(healthRoutes({...})) // bare mount + 'routes/monitoring.js', // apiRouter.use(monitoringRoutes({...})) // bare mount + 'routes/updates.js', // apiRouter.use(updatesRoutes({...})) // bare mount + 'routes/tailscale.js', // apiRouter.use('/tailscale', tailscaleRoutes({...})) + 'routes/sites.js', // apiRouter.use(sitesRoutes({...})) + 'routes/credentials.js', // apiRouter.use(credentialsRoutes({...})) + 'routes/backups.js', // apiRouter.use(backupsRoutes({...})) + 'routes/ca.js', // apiRouter.use('/ca', caRoutes(ctx)) + 'routes/browse.js', // apiRouter.use(browseRoutes({...})) + 'routes/errorlogs.js', // apiRouter.use(errorLogsRoutes({...})) + 'routes/logs.js', // apiRouter.use(logsRoutes({...})) + 'routes/openclaw.js', // apiRouter.use('/openclaw', openClawRoutes(ctx)) + 'routes/recipes/index.js', // apiRouter.use(recipesRoutes(ctx)) // bare mount + 'routes/config/index.js', // apiRouter.use(configRoutes(ctx)) // bare mount + 'routes/themes.js', // apiRouter.use(themesRoutes({...})) // bare mount + 'routes/license.js', // apiRouter.use('/license', licenseRoutes({...})) + ]; + // Prefix map: explicit prefix from src/app.js's apiRouter.use() call + const prefixMap = { + 'routes/dns.js': '/dns', + 'routes/notifications.js': '/notifications', + 'routes/containers.js': '/containers', + 'routes/tailscale.js': '/tailscale', + 'routes/ca.js': '/ca', + 'routes/openclaw.js': '/openclaw', + 'routes/license.js': '/license' + }; + for (const relPath of directMounts) { + const fullPath = path.join(PKG_ROOT, relPath); + if (!fs.existsSync(fullPath)) continue; + let factory; + try { + factory = require(fullPath); + } catch (e) { continue; } + if (typeof factory !== 'function') continue; + let router; + try { + router = factory(universalDeps); + } catch (e) { continue; } + // Every direct mount is on apiRouter (which lives at /api/v1) plus an + // optional explicit prefix from src/app.js. Walk with the combined prefix + // so /api/v1/services/X (bare mount) and /api/v1/ca/X (explicit /ca prefix) + // both match what production actually serves. + const prefix = '/api/v1' + (prefixMap[relPath] || ''); + walkRouter(router, prefix, mounted); + } + + // ----- 3. Inline routes in src/app.js (apiRouter.get, app.get, etc.) ----- + const appContent = fs.readFileSync(SRC_APP, 'utf8'); + const inlineCallRe = /(?:apiRouter|app|router)\.(?:get|post|put|delete|patch)\(\s*['"]([^'"]+)['"]/g; + for (const m of appContent.matchAll(inlineCallRe)) { + // Skip probe paths handled separately (they're not mounted on apiRouter) + if (!m[1].startsWith('/healthz') && !m[1].startsWith('/readyz')) { + // Some are root-level (e.g. '/health'), some are apiRouter-level (e.g. '/csrf-token') + // We add both interpretations — the source-of-truth check accepts either match + mounted.add(m[1]); + mounted.add('/api/v1' + m[1]); + } + } + // Also add the 5 probe paths explicitly since they're mounted at root + for (const p of ['/health', '/health/live', '/health/ready', '/healthz', '/readyz']) { + mounted.add(p); + } + + return mounted; +} + +// Recursively walk an Express router's stack to collect registered paths +function walkRouter(router, basePrefix, mounted) { + if (!router || !router.stack) return; + for (const layer of router.stack) { + if (layer.route) { + // Direct route registration: router.get('/path', handler) + const path = basePrefix + layer.route.path; + // Express adds regex objects; we want the path string + if (typeof path === 'string') { + mounted.add(path); + } + } else if (layer.name === 'router' && layer.handle.stack) { + // Sub-router mounted via router.use(subRouter) + // Express strips the mount path from layer.regex; reconstruct it from layer.regex + const mountPath = extractMountPath(layer); + walkRouter(layer.handle, basePrefix + mountPath, mounted); + } else if (layer.regex && layer.handle !== undefined) { + // Middleware with no path (e.g. router.use(initTotp(deps)) where initTotp + // returns a router). Express wraps it as a layer with regex.fast_slash=true. + // Try to walk it as a sub-router. + if (layer.handle && layer.handle.stack) { + const mountPath = extractMountPath(layer); + walkRouter(layer.handle, basePrefix + mountPath, mounted); + } + } + } +} + +// Extract the mount path from an Express layer's regex. +// Express stores it in layer.regex as a path-to-regexp regex; the source +// string is in layer.regex.source but it's been escaped. We can get the +// original path by parsing the source's leading '^\\/?(...)' or use a +// simpler heuristic: fast_slash layers mean mount was '/', otherwise +// reconstruct from the FastWildcard options. +// Since Express internals here are brittle, fall back to a regex source match. +function extractMountPath(layer) { + if (layer.regex && layer.regex.fast_slash) return ''; + if (!layer.regex || !layer.regex.source) return ''; + // The source is something like '^\\/foo\\/?(?=\\/|$)' for mount path '/foo'. + // Match the first path segment after the optional leading slash. + const m = layer.regex.source.match(/^\\\/\(([^)]+)\)/); + if (m) { + // Convert path-to-regexp syntax like ':foo' or '*' back to a placeholder. + // For simple mounts (no params) this gives us the literal segment. + return '/' + m[1]; + } + return ''; +} + +// Check if path is a prefix in PUBLIC_ROUTES (e.g., '/api/v1/auth/gate/' grants all under it) +function isPubliclyCovered(path, publicRoutes) { + if (publicRoutes.has(path)) return true; + // Try as prefix match + for (const entry of publicRoutes) { + if (entry.endsWith('/') && path.startsWith(entry)) return true; + if (entry === path) return true; + } + return false; +} + +describe('Public-routes allowlist drift (prevents DC-012-style dead entries)', () => { + const publicRoutes = readPublicRoutes(); + const csrfExcluded = readCsrfExcluded(); + const mountedRoutes = readMountedRoutes(); + + // Helpful diagnostic when tests fail + test('sanity: allowlists parsed correctly', () => { + expect(publicRoutes.size).toBeGreaterThan(10); + expect(csrfExcluded.size).toBeGreaterThan(0); + expect(mountedRoutes.size).toBeGreaterThan(10); + // Probe paths from DC-012 should all be in PUBLIC_ROUTES + for (const p of ['/health', '/health/live', '/health/ready', '/healthz', '/readyz']) { + expect(publicRoutes).toContain(p); + } + }); + + describe('No stale PUBLIC_ROUTES entries (the DC-012 failure mode)', () => { + test('every PUBLIC_ROUTES entry matches an actual mounted route', () => { + const stale = []; + for (const entry of publicRoutes) { + if (entry.endsWith('/')) continue; // prefix matches, skip + if (!mountedRoutes.has(entry)) stale.push(entry); + } + expect(stale).toEqual([]); + }); + }); + + describe('CSRF excludedPaths drift detection', () => { + test('every CSRF excludedPath is publicly accessible (else 403)', () => { + const broken = []; + for (const p of csrfExcluded) { + if (!isPubliclyCovered(p, publicRoutes)) broken.push(p); + } + expect(broken).toEqual([]); + }); + + test('probe paths are CSRF-exempt (k8s probes never carry CSRF tokens)', () => { + // These probe paths MUST be in csrf excludedPaths because k8s/Docker + // healthchecks hit them with GET requests and no CSRF token. + // (Note: CSRF middleware skips GET/HEAD/OPTIONS anyway, but explicit + // listing is the documented pattern and protects against future changes.) + for (const p of ['/health', '/health/live', '/health/ready', '/healthz', '/readyz']) { + expect(csrfExcluded).toContain(p); + } + }); + }); + + describe('Request-logging exclusion covers all probe paths', () => { + // The middleware.js request-logging skip is a regex-based check inside + // the logging middleware. We verify by reading the source and asserting + // each probe path appears in the skip set. + let middlewareContent; + beforeAll(() => { + middlewareContent = fs.readFileSync(SRC_MIDDLEWARE, 'utf8'); + }); + + for (const p of ['/health', '/health/live', '/health/ready', '/healthz', '/readyz']) { + test(`probe path '${p}' is excluded from request logging`, () => { + const pattern = new RegExp(`req\\.path\\s*===?\\s*['"]${p.replace(/\//g, '\\/')}['"]`); + expect(middlewareContent).toMatch(pattern); + }); + } + }); + + describe('Tailscale auth bypass covers all probe paths', () => { + // Same as logging exclusion but for the Tailscale auth middleware. + // K8s probes don't carry Tailscale identity headers. + let middlewareContent; + beforeAll(() => { + middlewareContent = fs.readFileSync(SRC_MIDDLEWARE, 'utf8'); + }); + + for (const p of ['/health', '/health/live', '/health/ready', '/healthz', '/readyz']) { + test(`probe path '${p}' bypasses Tailscale auth`, () => { + const pattern = new RegExp(`req\\.path\\s*===?\\s*['"]${p.replace(/\//g, '\\/')}['"]`); + expect(middlewareContent).toMatch(pattern); + }); + } + }); +}); diff --git a/dashcaddy-api/__tests__/test-helpers/universal-deps.js b/dashcaddy-api/__tests__/test-helpers/universal-deps.js new file mode 100644 index 0000000..604a3cc --- /dev/null +++ b/dashcaddy-api/__tests__/test-helpers/universal-deps.js @@ -0,0 +1,165 @@ +/** + * Shared universal-deps Proxy for tests that load real route modules with stub + * dependencies. Any property access returns a sensible value: + * - asyncHandler (the most common trap): pass-through returning its argument + * so `router.get('/path', asyncHandler(realHandler))` resolves to + * `router.get('/path', realHandler)` and Express sees a real handler + * - Other functions: noopFn returning undefined when called + * - Objects: recursive proxy + * + * Used by: + * - depth2-routes-smoke.test.js (verifies every depth-2 route module loads) + * - public-routes-drift.test.js (walks aggregator routers via Express stack) + */ +const noopFn = () => undefined; +const passThrough = (x) => x; + +// Logger-shaped noop: matches the real Logger's surface (error/warn/info/debug), +// so factories that do `log.error('tag', 'msg', meta)` or `(ctx.log || console).error(...)` +// don't blow up when run with stub deps. A bare `() => undefined` would throw because +// `noopFn.error` is undefined. +const loggerStub = { error: noopFn, warn: noopFn, info: noopFn, debug: noopFn, audit: noopFn }; + +const handler = { + get(target, prop, receiver) { + if (prop === 'asyncHandler') { + // asyncHandler is special — it must accept a handler function and return + // a wrapped handler function. Return a pass-through that wraps nothing. + // This is the most common trap: `router.get('/path', asyncHandler(realHandler))` + // resolves to `router.get('/path', realHandler)` and Express sees a real handler. + return (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); + } + if (prop === Symbol.toPrimitive) return undefined; + if (prop === 'then') return undefined; // don't make the proxy thenable + if (prop in target) return target[prop]; + // Functions and methods — return noopFn that returns undefined when called + if (typeof target[prop] === 'function') return target[prop]; + return noopFn; + }, + // Object.assign / spread / Object.keys on the proxy only sees the target's + // OWN enumerable keys. Without these traps, aggregator factories that copy + // ctx into a subCtx via `Object.assign({}, ctx, { helpers })` lose the + // proxy's magic (e.g. asyncHandler), and downstream factories fail with + // 'asyncHandler is not a function'. Expose all seed keys as own enumerable + // so they survive the copy. + ownKeys(target) { + return Reflect.ownKeys(target); + }, + getOwnPropertyDescriptor(target, prop) { + if (prop in target) return Object.getOwnPropertyDescriptor(target, prop); + return undefined; + } +}; + +// Seed the proxy with a few known-shape fields so modules that destructure +// them get the right type. Anything else falls back to noopFn via the handler. +const seed = { + fetchT: async () => ({ ok: true, status: 200, json: async () => ({}) }), + // asyncHandler is special — see handler.get below. We also seed it as an + // own enumerable property so Object.assign({}, ctx, { helpers }) copies it + // through (the proxy's ownKeys trap only exposes own keys, so anything not + // in the seed is invisible to spread/assign even though the get trap returns it). + asyncHandler: (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next), + servicesStateManager: { + read: async () => [], + write: async () => {}, + update: async () => [] + }, + siteConfig: { tld: '.home', dnsServers: {}, pylon: null }, + buildServiceUrl: (id) => `https://${id}.sami}`, + logError: async () => undefined, + // Logger-shaped stub (not a bare noopFn) so `(ctx.log || console).error(...)` + // and `log.error('tag','msg',meta)` calls don't throw. See loggerStub above. + log: loggerStub, + errorResponse: noopFn, + healthChecker: { + getCurrentStatus: () => ({}), + getServiceStats: () => null, + configureService: noopFn, + removeService: noopFn, + getOpenIncidents: () => [], + getIncidentHistory: () => [] + }, + authManager: {}, + credentialManager: { + store: async () => undefined, + retrieve: async () => null, + diagnose: async () => ({ status: 'missing' }), + rotateKey: async () => undefined + }, + totpConfig: { + isSetUp: false, + enabled: false, + sessionDuration: 'never', + getConfig: () => ({}), + saveConfig: async () => undefined + }, + saveTotpConfig: async () => undefined, + session: { + create: async () => ({}), + invalidate: async () => undefined, + isValid: () => true + }, + licenseManager: { + requirePremium: () => (req, res, next) => next(), + hasFeature: () => false + }, + getServiceById: () => null, + getAppSession: () => null, + appSessionCache: { get: () => null, set: noopFn }, + renewCSRFToken: () => 'csrf-token', + createCache: () => ({ get: () => null, set: noopFn }), + CACHE_CONFIGS: {}, + docker: {}, + notification: { send: noopFn }, + buildDomain: (s) => s, + caddy: {}, + addServiceToConfig: async () => undefined, + APP_TEMPLATES: {}, + DOCKER: {}, REGEX: {}, TIMEOUTS: {}, APP: {}, PLEX: {}, LIMITS: {}, + SESSION_TTL: 86400, + buildMediaAuth: () => ({}), + CADDY: {}, + DEFAULT_DNS_PORT: '5380', + isValidPort: () => true, + exists: async () => true, + validateURL: () => true, + validateToken: () => true, + validateAndLogConfig: () => ({}), + validateConfig: () => ({ valid: true, errors: [], warnings: [] }), + ValidationError: class extends Error {}, + AuthenticationError: class extends Error {}, + ForbiddenError: class extends Error {}, + NotFoundError: class extends Error {}, + ok: noopFn, + successMessage: noopFn, + validationError: noopFn, + notFound: noopFn, + error: noopFn, + platformPaths: {}, + RECIPE_TEMPLATES: {}, + RECIPE_CATEGORIES: [], + ARR_SERVICES: {}, + APP_PORTS: {}, + cryptoUtils: { encrypt: async (x) => x, decrypt: async (x) => x }, + // Path-like strings for routes that do `path.dirname(SERVICES_FILE)` etc + // before the factory body runs (e.g. routes/config/backup.js). Bare noopFn + // would throw 'path argument must be of type string. Received function'. + SERVICES_FILE: '/tmp/dashcaddy/services.json', + CONFIG_FILE: '/tmp/dashcaddy/config.json', + TOTP_CONFIG_FILE: '/tmp/dashcaddy/totp.json', + TAILSCALE_CONFIG_FILE: '/tmp/dashcaddy/tailscale.json', + NOTIFICATIONS_FILE: '/tmp/dashcaddy/notifications.json', + // Aggregator convenience: factories pass ctx.X into sub-router mounts; + // some sub-routers destructure these by name. Seed-as-own-property so + // Object.assign({}, ctx, { helpers }) copies them through. + loadSiteConfig: async () => ({}), + loadNotificationConfig: async () => ({}), + configStateManager: { read: async () => ({}), write: async () => undefined, update: async () => undefined }, + readConfig: async () => ({}), + saveConfig: async () => undefined, + helpers: {}, + safeErrorMessage: (e) => (e && e.message) || 'Unknown error' +}; + +module.exports = { universalDeps: new Proxy(seed, handler), noopFn, passThrough }; \ No newline at end of file