Files
dashcaddy/dashcaddy-api/src/context/index.js
T
Hermes 74e21c1edc [pending-judge] feat(DC-161): calendar widget + Radicale auto-wiring
- Top-bar calendar widget (status/js/calendar-widget.js): next-appointment
  display, settings modal with CalDAV/ICS source management and a
  Test-connection probe. Follows the weather-widget pattern.
- Backend routes/calendar.js: sources CRUD (secrets in credential manager,
  never in responses), aggregated events across sources with per-source
  error isolation, Radicale provision endpoint, CalDAV discover probe.
- src/calendar/ics-parser.js: dependency-free RFC 5545 parser with bounded
  RRULE expansion (DAILY/WEEKLY-BYDAY/MONTHLY/YEARLY, COUNT/UNTIL/INTERVAL,
  EXDATE consumes COUNT, RECURRENCE-ID overrides), 500-event cap.
- src/calendar/caldav-client.js: CalDAV REPORT time-range queries over
  node:https (no new deps), HTTPS-enforced off localhost, 5MiB response cap.
- src/calendar/radicale-provisioner.js: zero-config auto-wiring - generates
  a 24-char password, writes Radicale htpasswd config (pure-Node APR1 hash
  verified byte-for-byte against openssl passwd -apr1 vectors), stores
  credentials, registers the source. Deploy hook in routes/apps/deploy.js
  wires any Radicale deployed via the App Selector automatically.
- Radicale app template (tomsquest/docker-radicale) in the catalog.
- Tests: 32 new (parser unit, provisioner unit incl. openssl vectors,
  route tests). Full suite 3552 passed; only pre-existing pdf-parse
  billing/invoice failures remain (proven red at base 448f535).
- OpenAPI: 5 calendar paths documented. CHANGELOG entry added.
2026-10-07 19:57:22 -07:00

264 lines
7.3 KiB
JavaScript

/**
* Context assembly - Dependency injection container
* Assembles all context objects needed by routes
*/
const { createDockerContext } = require('./docker');
const { createCaddyContext } = require('./caddy');
const { createDnsContext } = require('./dns');
const { createSessionContext } = require('./session');
const NotificationManager = require('../managers/notification-manager');
const tailscaleManager = require('../managers/tailscale-manager');
const { TailscaleCoordClient } = require('../managers/tailscale-coord');
const fs = require('fs');
/**
* Assemble the full application context
* This replaces the old "god object" ctx with explicit construction
*/
function assembleContext({
// Config
siteConfig,
buildDomain,
buildServiceUrl,
SERVICES_FILE,
CONFIG_FILE,
TOTP_CONFIG_FILE,
TAILSCALE_CONFIG_FILE,
NOTIFICATIONS_FILE,
ERROR_LOG_FILE,
DNS_CREDENTIALS_FILE,
CADDYFILE_PATH,
CADDY_ADMIN_URL,
// State managers
servicesStateManager,
configStateManager,
calendarStateManager,
// DC-053 share store
shareStore,
// Managers
credentialManager,
authManager,
licenseManager,
healthChecker,
updateManager,
backupManager,
resourceMonitor,
auditLogger,
portLockManager,
selfUpdater,
dockerMaintenance,
logDigest,
dockerSecurity,
// Templates
APP_TEMPLATES,
TEMPLATE_CATEGORIES,
DIFFICULTY_LEVELS,
RECIPE_TEMPLATES,
RECIPE_CATEGORIES,
// Helpers
asyncHandler,
errorResponse,
ok,
fetchT,
httpsAgent,
log,
logError,
safeErrorMessage,
getServiceById,
readConfig,
saveConfig,
addServiceToConfig,
validateURL,
strictLimiter,
totpConfig,
saveTotpConfig,
loadSiteConfig,
loadNotificationConfig,
resyncHealthChecker,
// Middleware result
middlewareResult,
// App
app,
}) {
// Create domain-specific contexts
const docker = createDockerContext(dockerSecurity);
const caddy = createCaddyContext(CADDYFILE_PATH, CADDY_ADMIN_URL, fetchT, httpsAgent, log, siteConfig, buildDomain);
const dns = createDnsContext(siteConfig, buildDomain, credentialManager, fetchT, httpsAgent, log, DNS_CREDENTIALS_FILE);
const session = createSessionContext(middlewareResult);
// Create notification manager
const notification = new NotificationManager({
NOTIFICATIONS_FILE,
fetchT,
docker,
log,
config: siteConfig
});
// --- Tailscale coordination API client --------------------------------------
// Reads the API token from credentialManager on every call (not cached on
// the client) so that PUT /api/v1/tailscale/settings takes effect
// immediately without restarting the process. The metadata file
// tailscale-config.json stores non-secret state (tailnet name, last
// validation time, device count) so we don't have to hit the API just to
// answer "is this configured?" in the UI.
function loadTailscaleMetadata() {
try {
if (TAILSCALE_CONFIG_FILE && fs.existsSync(TAILSCALE_CONFIG_FILE)) {
return JSON.parse(fs.readFileSync(TAILSCALE_CONFIG_FILE, 'utf8'));
}
} catch (_e) { /* corrupt file → treat as unconfigured */ }
return { configured: false };
}
function saveTailscaleMetadata(meta) {
if (!TAILSCALE_CONFIG_FILE) return;
try {
fs.writeFileSync(TAILSCALE_CONFIG_FILE, JSON.stringify(meta, null, 2), 'utf8');
} catch (e) {
log.error('tailscale-coord', e, null, { note: 'Failed to write tailscale-config.json' });
}
}
async function getCoordClient() {
const tok = await credentialManager.retrieve('tailscale.coord.apiToken');
return new TailscaleCoordClient({ apiToken: tok || null });
}
// Assemble flat context (temporary - routes still expect this)
// Note: tailscale interface detection lives in src/utilities/network-detector.js
// (DC-031). The empty `tailscale` stub previously wired here was dead code
// — verified zero readers via grep across src/.
const ctx = {
// Namespaced contexts
docker,
caddy,
dns,
session,
notification,
// Tailscale manager — wraps `tailscale status --json` with 5min cache.
// Replaces the long-standing null stub at src/app.js:189. See
// src/managers/tailscale-manager.js for full API surface.
tailscale: {
getStatus: tailscaleManager.getStatus,
getLocalIP: tailscaleManager.getLocalIP,
getSummary: tailscaleManager.getSummary,
getDevices: tailscaleManager.getDevices,
isTailscaleIP: tailscaleManager.isTailscaleIP,
invalidateCache: tailscaleManager.invalidateCache,
getAccessToken: tailscaleManager.getAccessToken,
startSyncTimer: tailscaleManager.startSyncTimer,
stopSyncTimer: tailscaleManager.stopSyncTimer,
syncAPI: tailscaleManager.syncAPI,
},
// Tailscale coordination API client — talk to api.tailscale.com for
// device management, pre-auth key creation, ACL reads/writes, and user
// listing. Distinct from the local tailscaleManager above (which reads
// the local tailscaled daemon). The API token is stored encrypted via
// credentialManager and re-read on every call so settings changes take
// effect without process restart.
tailscaleCoord: {
// Returns a fresh client each call — cheap (just a Map + token lookup),
// and guarantees the latest token is used.
getClient: getCoordClient,
// Metadata helpers — read/write tailscale-config.json
loadMetadata: loadTailscaleMetadata,
saveMetadata: saveTailscaleMetadata,
// Storage helpers — wraps credentialManager so route code doesn't
// need to know the key naming convention.
setApiToken: async (token) => {
if (token) {
await credentialManager.store('tailscale.coord.apiToken', token, {
description: 'Tailscale coordination API token',
source: 'settings-ui',
});
} else {
await credentialManager.delete('tailscale.coord.apiToken');
}
},
hasApiToken: async () => {
const tok = await credentialManager.retrieve('tailscale.coord.apiToken');
return !!tok;
},
},
// App and config
app,
siteConfig,
// State managers
servicesStateManager,
configStateManager,
calendarStateManager,
// DC-053 share store
shareStore,
// Managers
credentialManager,
authManager,
licenseManager,
healthChecker,
updateManager,
backupManager,
resourceMonitor,
auditLogger,
portLockManager,
selfUpdater,
dockerMaintenance,
logDigest,
// Templates
APP_TEMPLATES,
TEMPLATE_CATEGORIES,
DIFFICULTY_LEVELS,
RECIPE_TEMPLATES,
RECIPE_CATEGORIES,
// Helpers
asyncHandler,
errorResponse,
ok,
fetchT,
log,
logError,
safeErrorMessage,
buildDomain,
buildServiceUrl,
getServiceById,
readConfig,
saveConfig,
addServiceToConfig,
validateURL,
strictLimiter,
// Config helpers
totpConfig,
saveTotpConfig,
loadSiteConfig,
loadNotificationConfig,
resyncHealthChecker,
// Middleware result (exposes renewCSRFToken etc.)
middlewareResult,
// File paths
SERVICES_FILE,
CONFIG_FILE,
TOTP_CONFIG_FILE,
TAILSCALE_CONFIG_FILE,
NOTIFICATIONS_FILE,
ERROR_LOG_FILE,
};
return ctx;
}
module.exports = { assembleContext };