/** * DashCaddy JavaScript Client — lightweight SDK for the DashCaddy API. * * Zero external dependencies. Works in Node.js 18+ (uses global fetch). * * @example * const { DashCaddyClient } = require('./dashcaddy-client'); * * // API key auth (simplest — no CSRF needed) * const client = new DashCaddyClient({ * baseUrl: 'https://status.sami', * apiKey: 'dk_abc123_xyz' * }); * * // Session cookie auth (CSRF handled automatically) * const client2 = new DashCaddyClient({ * baseUrl: 'https://status.sami', * sessionCookie: 'sid=...' * }); * * // List services * const services = await client.services.list(); * * // Get health status * const health = await client.health.get(); * * // Discover containers * const { containers } = await client.containers.discover(); * * // Create a DNS record * await client.dns.createRecord({ type: 'A', domain: 'app.sami', value: '10.0.0.1' }); * * // Run an immediate backup * const { backup } = await client.backups.execute(); * * @license MIT */ 'use strict'; // ── Constants ────────────────────────────────────────────────── const DEFAULT_TIMEOUT = 30000; const DEFAULT_MAX_RETRIES = 3; const RETRY_BACKOFF_BASE_MS = 500; const API_PREFIX = '/api/v1'; const HEALTH_PREFIX = ''; const CSRF_PATH = API_PREFIX + '/csrf-token'; const CSRF_HEADER_NAME = 'x-csrf-token'; const API_KEY_HEADER = 'x-api-key'; // ── Error Class ──────────────────────────────────────────────── /** * Error thrown when the API returns a non-success response or a network * error occurs after all retries are exhausted. */ class DashCaddyError extends Error { /** * @param {string} message - Error message. * @param {number} [statusCode] - HTTP status code. * @param {string} [code] - Machine-readable error code from the API. * @param {Record} [details] - Full error response body. */ constructor(message, statusCode, code, details) { super(message); this.name = 'DashCaddyError'; this.statusCode = statusCode || 0; this.code = code; this.details = details; } } // ── Internal HTTP Request Helper ─────────────────────────────── /** * @param {Object} opts * @param {string} opts.url * @param {string} opts.method * @param {Record} [opts.headers] * @param {unknown} [opts.body] * @param {number} [opts.timeout] * @param {typeof fetch} [opts.fetchImpl] * @param {AbortSignal} [opts.signal] * @returns {Promise} */ async function rawRequest({ url, method, headers, body, timeout, fetchImpl, signal }) { const fetchFn = fetchImpl || fetch; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout || DEFAULT_TIMEOUT); // Link external signal if provided if (signal) { if (signal.aborted) controller.abort(); else signal.addEventListener('abort', () => controller.abort(), { once: true }); } try { const res = await fetchFn(url, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, signal: controller.signal, }); return res; } finally { clearTimeout(timer); } } // ── Resource Mixins ──────────────────────────────────────────── // Each resource namespace is created as a plain object with methods bound // to the client instance. This keeps the class lean while providing // structured access: client.services.list(), client.health.get(), etc. /** * @param {DashCaddyClient} client * @returns {Object} */ function createServicesResource(client) { return { /** List all registered services. GET /api/v1/services */ async list() { const res = await client._request('GET', '/services'); return res; }, /** * Get aggregated status for all services. GET /api/v1/services/status * @returns {Promise<{ success: boolean, checkedAt?: string, partial?: boolean, statuses?: Record }>} */ async status() { return client._request('GET', '/services/status'); }, /** * Create a new service. POST /api/v1/services * @param {object} service - Service definition. */ async create(service) { return client._request('POST', '/services', { body: service }); }, /** * Update services (bulk replace). PUT /api/v1/services * @param {object[]} services - Full services array. */ async updateAll(services) { return client._request('PUT', '/services', { body: services }); }, /** * Delete a service by ID. DELETE /api/v1/services/:id * @param {string} id - Service ID. */ async delete(id) { return client._request('DELETE', `/services/${encodeURIComponent(id)}`); }, /** * Trigger a services update check/apply. POST /api/v1/services/update * @param {object} [opts] - Update options. */ async triggerUpdate(opts) { return client._request('POST', '/services/update', { body: opts || {} }); }, }; } /** * @param {DashCaddyClient} client * @returns {Object} */ function createContainersResource(client) { return { /** Discover all Docker containers. GET /api/v1/containers/discover */ async discover() { return client._request('GET', '/containers/discover'); }, /** * Get logs for a container. GET /api/v1/containers/:id/logs * @param {string} id - Container ID. */ async logs(id) { return client._request('GET', `/containers/${encodeURIComponent(id)}/logs`); }, /** * Get resource limits for a container. GET /api/v1/containers/:id/resources * @param {string} id - Container ID. */ async resources(id) { return client._request('GET', `/containers/${encodeURIComponent(id)}/resources`); }, /** * Check if a container image update is available. * GET /api/v1/containers/:id/check-update * @param {string} id - Container ID. */ async checkUpdate(id) { return client._request('GET', `/containers/${encodeURIComponent(id)}/check-update`); }, /** Start a container. POST /api/v1/containers/:id/start */ async start(id) { return client._request('POST', `/containers/${encodeURIComponent(id)}/start`, { body: {} }); }, /** Stop a container. POST /api/v1/containers/:id/stop */ async stop(id) { return client._request('POST', `/containers/${encodeURIComponent(id)}/stop`, { body: {} }); }, /** Restart a container. POST /api/v1/containers/:id/restart */ async restart(id) { return client._request('POST', `/containers/${encodeURIComponent(id)}/restart`, { body: {} }); }, /** * Update a container image. POST /api/v1/containers/:id/update * @param {string} id - Container ID. * @param {object} [opts] - Update options. */ async update(id, opts) { return client._request('POST', `/containers/${encodeURIComponent(id)}/update`, { body: opts || {} }); }, /** Remove a container. DELETE /api/v1/containers/:id */ async remove(id) { return client._request('DELETE', `/containers/${encodeURIComponent(id)}`); }, }; } /** * @param {DashCaddyClient} client * @returns {Object} */ function createHealthResource(client) { return { /** Liveness check (root-level). GET /health */ async get() { return client._request('GET', '/health', { root: true }); }, /** Liveness probe. GET /health/live */ async live() { return client._request('GET', '/health/live', { root: true }); }, /** Readiness probe. GET /health/ready */ async ready() { return client._request('GET', '/health/ready', { root: true }); }, /** Health status for all services. GET /api/v1/health/services */ async services() { return client._request('GET', '/health/services'); }, /** Cached health (no re-probe). GET /api/v1/health/cached */ async cached() { return client._request('GET', '/health/cached'); }, /** * Health for a specific service. GET /api/v1/health/service/:id * @param {string} id - Service ID. */ async service(id) { return client._request('GET', `/health/service/${encodeURIComponent(id)}`); }, /** CA certificate health. GET /api/v1/health/ca */ async ca() { return client._request('GET', '/health/ca'); }, }; } /** * @param {DashCaddyClient} client * @returns {Object} */ function createDnsResource(client) { return { /** List DNS providers. GET /api/v1/dns/providers */ async providers() { return client._request('GET', '/dns/providers'); }, /** DNS provider status. GET /api/v1/dns/provider/status */ async providerStatus() { return client._request('GET', '/dns/provider/status'); }, /** * Create a DNS record. POST /api/v1/dns/record * @param {object} record - DNS record definition. */ async createRecord(record) { return client._request('POST', '/dns/record', { body: record }); }, /** * Create a DNS record (universal path). POST /api/v1/dns/universal/record * @param {object} record - DNS record definition. */ async createUniversalRecord(record) { return client._request('POST', '/dns/universal/record', { body: record }); }, /** * Delete a DNS record. DELETE /api/v1/dns/record * @param {object} record - Record identifier fields. */ async deleteRecord(record) { return client._request('DELETE', '/dns/record', { body: record }); }, /** * Resolve a DNS record. GET /api/v1/dns/resolve * @param {object} params - Query params (domain, type). */ async resolve(params) { return client._request('GET', '/dns/resolve', { query: params }); }, /** DNS credentials. GET /api/v1/dns/credentials */ async credentials() { return client._request('GET', '/dns/credentials'); }, /** * Set DNS credentials. POST /api/v1/dns/credentials * @param {object} creds - Provider credentials. */ async setCredentials(creds) { return client._request('POST', '/dns/credentials', { body: creds }); }, /** * Check DNS propagation for a domain. GET /api/v1/dns/propagation/:domain * @param {string} domain - Domain to check. */ async propagation(domain) { return client._request('GET', `/dns/propagation/${encodeURIComponent(domain)}`); }, }; } /** * @param {DashCaddyClient} client * @returns {Object} */ function createBackupsResource(client) { return { /** Get backup config. GET /api/v1/backups/config */ async getConfig() { return client._request('GET', '/backups/config'); }, /** * Update backup config. POST /api/v1/backups/config * @param {object} config - Backup config patch. */ async updateConfig(config) { return client._request('POST', '/backups/config', { body: config }); }, /** * Execute an immediate backup. POST /api/v1/backups/execute * @param {object} [opts] - Backup options. */ async execute(opts) { return client._request('POST', '/backups/execute', { body: opts || {} }); }, /** * Get backup history. GET /api/v1/backups/history * @param {number} [limit=50] - Max entries. */ async history(limit) { const query = limit ? { limit: String(limit) } : undefined; return client._request('GET', '/backups/history', { query }); }, /** Get backup storage info. GET /api/v1/backups/storage-info */ async storageInfo() { return client._request('GET', '/backups/storage-info'); }, /** * Restore from a backup. POST /api/v1/backups/restore/:backupId * @param {string} backupId - Backup ID. * @param {object} [opts] - Restore options. */ async restore(backupId, opts) { return client._request('POST', `/backups/restore/${encodeURIComponent(backupId)}`, { body: opts || {} }); }, /** List backup files. GET /api/v1/backups/files */ async files() { return client._request('GET', '/backups/files'); }, }; } /** * @param {DashCaddyClient} client * @returns {Object} */ function createConfigResource(client) { return { /** Get site configuration. GET /api/v1/config */ async get() { return client._request('GET', '/config'); }, /** * Update site configuration. POST /api/v1/config * @param {object} config - Config patch (merged with existing). */ async update(config) { return client._request('POST', '/config', { body: config }); }, }; } /** * @param {DashCaddyClient} client * @returns {Object} */ function createMonitoringResource(client) { return { /** Aggregated resource stats for all containers. GET /api/v1/monitoring/stats */ async stats() { return client._request('GET', '/monitoring/stats'); }, /** * Resource stats for a specific container. GET /api/v1/monitoring/stats/:containerId * @param {string} containerId - Container ID. */ async containerStats(containerId) { return client._request('GET', `/monitoring/stats/${encodeURIComponent(containerId)}`); }, /** * Historical stats for a container. GET /api/v1/monitoring/history/:containerId * @param {string} containerId - Container ID. * @param {object} [query] - e.g. { hours: 24 } or { startTime, endTime }. */ async history(containerId, query) { return client._request('GET', `/monitoring/history/${encodeURIComponent(containerId)}`, { query }); }, /** Alert configuration. GET /api/v1/monitoring/alerts/config */ async alertConfig() { return client._request('GET', '/monitoring/alerts/config'); }, /** * Update alert configuration. POST /api/v1/monitoring/alerts/config * @param {object} config - Alert config. */ async updateAlertConfig(config) { return client._request('POST', '/monitoring/alerts/config', { body: config }); }, /** List configured alerts. GET /api/v1/monitoring/alerts */ async alerts() { return client._request('GET', '/monitoring/alerts'); }, }; } // ── Main Client Class ────────────────────────────────────────── /** * DashCaddy API client. * * Handles authentication (API key, session cookie, or TOTP session), * automatic CSRF token management, retry on 5xx errors, and provides * structured access to all major resource types. */ class DashCaddyClient { /** * @param {object} options * @param {string} options.baseUrl - Base URL, e.g. 'https://status.sami'. * @param {string} [options.apiKey] - API key (dk__). Bypasses CSRF. * @param {string} [options.sessionCookie] - Session cookie value for cookie auth. * @param {string} [options.csrfToken] - Pre-fetched CSRF token. * @param {number} [options.timeout=30000] - Request timeout in ms. * @param {number} [options.maxRetries=3] - Max retries on 5xx. * @param {Record} [options.headers] - Extra default headers. * @param {typeof fetch} [options.fetch] - Custom fetch implementation. */ constructor(options) { if (!options || !options.baseUrl) { throw new Error('DashCaddyClient: baseUrl is required'); } this.baseUrl = options.baseUrl.replace(/\/+$/, ''); this.apiKey = options.apiKey || null; this.sessionCookie = options.sessionCookie || null; this._csrfToken = options.csrfToken || null; this.timeout = options.timeout || DEFAULT_TIMEOUT; this.maxRetries = options.maxRetries !== undefined ? options.maxRetries : DEFAULT_MAX_RETRIES; this.extraHeaders = options.headers || {}; this._fetchImpl = options.fetch || null; // API key auth bypasses CSRF entirely this._useApiKey = !!this.apiKey; // Resource namespaces this.services = createServicesResource(this); this.containers = createContainersResource(this); this.health = createHealthResource(this); this.dns = createDnsResource(this); this.backups = createBackupsResource(this); this.config = createConfigResource(this); this.monitoring = createMonitoringResource(this); } // ── CSRF Token Management ── /** * Fetch and cache a CSRF token (needed for session-cookie auth on * state-changing requests). Skipped automatically when using API key auth. * @returns {Promise} */ async ensureCsrfToken() { if (this._useApiKey) return null; if (this._csrfToken) return this._csrfToken; try { const res = await this._request('GET', '/csrf-token', { _skipCsrf: true }); this._csrfToken = res.token || null; return this._csrfToken; } catch (_) { // CSRF fetch failed — proceed without; server will reject if needed return null; } } // ── Core Request Method ── /** * Internal: perform an authenticated API request with retry logic. * * @param {string} method - HTTP method (GET, POST, PUT, DELETE, PATCH). * @param {string} path - Path after the API base (e.g. '/services'). * @param {object} [opts] * @param {unknown} [opts.body] - Request body (JSON-serialized). * @param {Record} [opts.query] - Query string params. * @param {boolean} [opts.root=false] - If true, path is root-level (e.g. /health). * @param {boolean} [opts._skipCsrf=false] - Internal: skip CSRF token injection. * @param {AbortSignal} [opts.signal] - External abort signal. * @returns {Promise} The parsed response body (spread from the success envelope). * @throws {DashCaddyError} On non-success response or network failure after retries. * @private */ async _request(method, path, opts = {}) { const { body, query, root, _skipCsrf, signal } = opts; // Build URL const prefix = root ? HEALTH_PREFIX : API_PREFIX; let url = `${this.baseUrl}${prefix}${path}`; if (query) { const qs = new URLSearchParams( Object.entries(query).filter(([, v]) => v !== undefined && v !== null) ).toString(); if (qs) url += `?${qs}`; } // Determine if CSRF is needed for this request const isStateChanging = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method.toUpperCase()); const needsCsrf = isStateChanging && !_skipCsrf && !this._useApiKey; // CSRF token: ensure we have one for state-changing requests (session auth) let csrfToken = this._csrfToken; if (needsCsrf && !csrfToken) { csrfToken = await this.ensureCsrfToken(); } // Build headers const headers = { 'Content-Type': 'application/json', ...this.extraHeaders, }; if (this._useApiKey) { headers[API_KEY_HEADER] = this.apiKey; } if (this.sessionCookie) { headers['Cookie'] = this.sessionCookie; } if (csrfToken && !_skipCsrf) { headers[CSRF_HEADER_NAME] = csrfToken; } // Retry loop let lastError = null; for (let attempt = 1; attempt <= this.maxRetries; attempt++) { try { const res = await rawRequest({ url, method, headers, body, timeout: this.timeout, fetchImpl: this._fetchImpl, signal, }); // Parse response body let json = null; const text = await res.text(); if (text) { try { json = JSON.parse(text); } catch (_) { // Non-JSON response — wrap it json = { success: res.ok, raw: text }; } } // Retry on 5xx if (res.status >= 500 && attempt < this.maxRetries) { await this._backoff(attempt); continue; } // Check envelope if (json && json.success === false) { const errorMsg = json.error || `Request failed with status ${res.status}`; throw new DashCaddyError(errorMsg, res.status, json.code, json); } if (!res.ok && !(json && json.success === true)) { const errorMsg = (json && json.error) || `HTTP ${res.status}`; throw new DashCaddyError(errorMsg, res.status, json && json.code, json); } // Success — return the full envelope (minus the success flag is caller's choice) // We return the spread data: everything except `success` for convenience, // but also keep success for callers who want to check it. return json || { success: true }; } catch (err) { // Network errors (AbortError, TypeError) — retry if attempts remain if (err instanceof DashCaddyError) { // 5xx errors that exhausted retries are re-thrown if (err.statusCode >= 500 && attempt < this.maxRetries) { lastError = err; await this._backoff(attempt); continue; } throw err; } // Network-level error lastError = err; if (attempt < this.maxRetries) { await this._backoff(attempt); continue; } throw new DashCaddyError( err.name === 'AbortError' ? `Request timeout after ${this.timeout}ms` : `Network error: ${err.message}`, 0, 'NETWORK_ERROR', { originalError: err.message } ); } } // Should not reach here, but guard just in case throw lastError || new DashCaddyError('Request failed after all retries', 0); } /** * Exponential backoff with jitter. * @param {number} attempt - Current attempt number (1-based). * @returns {Promise} * @private */ async _backoff(attempt) { const delay = RETRY_BACKOFF_BASE_MS * Math.pow(2, attempt - 1); const jitter = Math.random() * delay * 0.3; await new Promise((resolve) => setTimeout(resolve, delay + jitter)); } // ── Auth Helpers ── /** * Exchange an API key for a JWT token. * POST /api/v1/auth/jwt * @param {string} [apiKey] - Override the client's API key. * @returns {Promise} */ async exchangeJwt(apiKey) { const key = apiKey || this.apiKey; if (!key) throw new DashCaddyError('API key required for JWT exchange', 0, 'NO_API_KEY'); return this._request('POST', '/auth/jwt', { body: { apiKey: key }, _skipCsrf: true }); } /** * Verify a TOTP code to establish a session. * POST /api/v1/totp/verify * @param {string} code - TOTP code from authenticator. * @returns {Promise} Includes csrfToken and ssoToken on success. */ async verifyTotp(code) { const res = await this._request('POST', '/totp/verify', { body: { code }, _skipCsrf: true }); // Cache the CSRF token returned after TOTP login if (res.csrfToken) { this._csrfToken = res.csrfToken; } return res; } /** * Get the current API version. GET /api/v1/version * @returns {Promise} */ async version() { return this._request('GET', '/version'); } /** * Get API metrics summary. GET /api/v1/metrics * @returns {Promise} */ async metrics() { return this._request('GET', '/metrics'); } } // ── Exports ──────────────────────────────────────────────────── module.exports = { DashCaddyClient, DashCaddyError }; module.exports.DashCaddyClient = DashCaddyClient; module.exports.DashCaddyError = DashCaddyError;