/** * DashCaddy JavaScript Client — lightweight SDK for the DashCaddy API. * * Zero external dependencies. Works in Node.js 18+ (uses global fetch). * Full TypeScript definitions in types.d.ts. * * @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=...' * }); * * const services = await client.services.list(); // GET /api/v1/services * const health = await client.health.get(); // GET /health * const { containers } = await client.containers.discover(); * await client.dns.createRecord({ type: 'A', domain: 'app.sami', value: '10.0.0.1' }); * const { backup } = await client.backups.execute(); * * @license MIT */ 'use strict'; const DEFAULT_TIMEOUT = 30000; const DEFAULT_MAX_RETRIES = 3; const RETRY_BASE_MS = 500; const API_PREFIX = '/api/v1'; const CSRF_HEADER = 'x-csrf-token'; const API_KEY_HEADER = 'x-api-key'; /** Error thrown on non-success API responses or network failures after retries. */ class DashCaddyError extends Error { constructor(message, statusCode, code, details) { super(message); this.name = 'DashCaddyError'; this.statusCode = statusCode || 0; this.code = code; this.details = details; } } // ── Client ───────────────────────────────────────────────────── 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; this._useApiKey = !!this.apiKey; // Resource namespaces — defined via compact spec tables below this.services = this._buildResource(SERVICES_SPEC); this.containers = this._buildResource(CONTAINERS_SPEC); this.health = this._buildResource(HEALTH_SPEC); this.dns = this._buildResource(DNS_SPEC); this.backups = this._buildResource(BACKUPS_SPEC); this.config = this._buildResource(CONFIG_SPEC); this.monitoring = this._buildResource(MONITORING_SPEC); } /** * Build a resource namespace from a compact method spec. * Each spec entry: [methodName, httpMethod, pathTemplate, needsBody, isRoot] * pathTemplate uses :param placeholders substituted from args[0..n]. * isRoot=true means the path is root-level (no /api/v1 prefix), e.g. /health. * @private */ _buildResource(spec) { const client = this; const obj = {}; for (const entry of spec) { const [name, httpMethod, pathTpl, hasBody, isRoot] = entry; obj[name] = async function (...args) { let path = pathTpl; // Substitute :param placeholders from positional args (strings/numbers only) const params = pathTpl.match(/:[\w]+/g) || []; let argIdx = 0; for (const param of params) { if (argIdx < args.length) { path = path.replace(param, encodeURIComponent(String(args[argIdx++]))); } } // Body or query is the next arg after path params const nextArg = args[argIdx]; const opts = { root: isRoot || false }; if (hasBody) opts.body = nextArg || {}; else if (nextArg && typeof nextArg === 'object') opts.query = nextArg; return client._request(httpMethod, path, opts); }; } return obj; } /** * Fetch and cache a CSRF token (session-cookie auth only). * @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 (_) { return null; } } /** * Core request: builds URL + headers, handles auth, retries 5xx. * @param {string} method - HTTP method. * @param {string} path - Path after API prefix (or root-level if opts.root). * @param {object} [opts] - { body, query, root, _skipCsrf, signal }. * @returns {Promise} Parsed response (success envelope spread). * @private */ async _request(method, path, opts = {}) { const { body, query, root, _skipCsrf, signal } = opts; // Build URL let url = `${this.baseUrl}${root ? '' : API_PREFIX}${path}`; if (query) { const qs = new URLSearchParams( Object.entries(query).filter(([, v]) => v !== undefined && v !== null) ).toString(); if (qs) url += `?${qs}`; } // CSRF: needed for state-changing requests in session-cookie mode const isStateChanging = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method.toUpperCase()); const needsCsrf = isStateChanging && !_skipCsrf && !this._useApiKey; let csrfToken = this._csrfToken; if (needsCsrf && !csrfToken) csrfToken = await this.ensureCsrfToken(); // 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] = csrfToken; // Retry loop let lastError; for (let attempt = 1; attempt <= this.maxRetries; attempt++) { try { const res = await this._fetch(url, method, headers, body, signal); const text = await res.text(); let json = null; if (text) { try { json = JSON.parse(text); } catch (_) { json = { success: res.ok, raw: text }; } } // Retry on 5xx if (res.status >= 500 && attempt < this.maxRetries) { await this._backoff(attempt); continue; } // Envelope check if (json && json.success === false) { throw new DashCaddyError(json.error || `Status ${res.status}`, res.status, json.code, json); } if (!res.ok && !(json && json.success === true)) { throw new DashCaddyError((json && json.error) || `HTTP ${res.status}`, res.status, json && json.code, json); } return json || { success: true }; } catch (err) { if (err instanceof DashCaddyError) { if (err.statusCode >= 500 && attempt < this.maxRetries) { lastError = err; await this._backoff(attempt); continue; } throw err; } lastError = err; if (attempt < this.maxRetries) { await this._backoff(attempt); continue; } throw new DashCaddyError( err.name === 'AbortError' ? `Timeout after ${this.timeout}ms` : `Network error: ${err.message}`, 0, 'NETWORK_ERROR', { originalError: err.message } ); } } throw lastError || new DashCaddyError('Request failed after all retries', 0); } /** Low-level fetch with timeout. @private */ async _fetch(url, method, headers, body, externalSignal) { const fetchFn = this._fetchImpl || fetch; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), this.timeout); if (externalSignal) { if (externalSignal.aborted) controller.abort(); else externalSignal.addEventListener('abort', () => controller.abort(), { once: true }); } try { return await fetchFn(url, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, signal: controller.signal, }); } finally { clearTimeout(timer); } } /** Exponential backoff with jitter. @private */ async _backoff(attempt) { const delay = RETRY_BASE_MS * Math.pow(2, attempt - 1); await new Promise(r => setTimeout(r, delay + Math.random() * delay * 0.3)); } // ── Auth & System Helpers ── /** Exchange API key for JWT. POST /api/v1/auth/jwt */ async exchangeJwt(apiKey) { const key = apiKey || this.apiKey; if (!key) throw new DashCaddyError('API key required', 0, 'NO_API_KEY'); return this._request('POST', '/auth/jwt', { body: { apiKey: key }, _skipCsrf: true }); } /** Verify TOTP and establish session. POST /api/v1/totp/verify */ async verifyTotp(code) { const res = await this._request('POST', '/totp/verify', { body: { code }, _skipCsrf: true }); if (res.csrfToken) this._csrfToken = res.csrfToken; return res; } /** Get API version. GET /api/v1/version */ async version() { return this._request('GET', '/version'); } /** Get metrics summary. GET /api/v1/metrics */ async metrics() { return this._request('GET', '/metrics'); } } // ── Resource Specs ───────────────────────────────────────────── // [methodName, httpMethod, pathTemplate, hasBody] // Path params (:id) are filled from positional string/number args. // For hasBody=true, the arg after path params is the body. // For hasBody=false, an object arg after path params is treated as query params. const SERVICES_SPEC = [ ['list', 'GET', '/services', false], ['status', 'GET', '/services/status', false], ['create', 'POST', '/services', true], ['updateAll', 'PUT', '/services', true], ['delete', 'DELETE', '/services/:id', false], ['triggerUpdate', 'POST', '/services/update', true], ]; const CONTAINERS_SPEC = [ ['discover', 'GET', '/containers/discover', false], ['logs', 'GET', '/containers/:id/logs', false], ['resources', 'GET', '/containers/:id/resources', false], ['checkUpdate', 'GET', '/containers/:id/check-update', false], ['start', 'POST', '/containers/:id/start', true], ['stop', 'POST', '/containers/:id/stop', true], ['restart', 'POST', '/containers/:id/restart', true], ['update', 'POST', '/containers/:id/update', true], ['remove', 'DELETE', '/containers/:id', false], ]; const HEALTH_SPEC = [ ['get', 'GET', '/health', false, true], ['live', 'GET', '/health/live', false, true], ['ready', 'GET', '/health/ready', false, true], ['services', 'GET', '/health/services', false], ['cached', 'GET', '/health/cached', false], ['service', 'GET', '/health/service/:id', false], ['ca', 'GET', '/health/ca', false], ]; const DNS_SPEC = [ ['providers', 'GET', '/dns/providers', false], ['providerStatus', 'GET', '/dns/provider/status', false], ['createRecord', 'POST', '/dns/record', true], ['createUniversal', 'POST', '/dns/universal/record', true], ['deleteRecord', 'DELETE', '/dns/record', true], ['resolve', 'GET', '/dns/resolve', false], ['credentials', 'GET', '/dns/credentials', false], ['setCredentials', 'POST', '/dns/credentials', true], ['propagation', 'GET', '/dns/propagation/:domain', false], ]; const BACKUPS_SPEC = [ ['getConfig', 'GET', '/backups/config', false], ['updateConfig', 'POST', '/backups/config', true], ['execute', 'POST', '/backups/execute', true], ['history', 'GET', '/backups/history', false], ['storageInfo', 'GET', '/backups/storage-info', false], ['restore', 'POST', '/backups/restore/:backupId', true], ['files', 'GET', '/backups/files', false], ]; const CONFIG_SPEC = [ ['get', 'GET', '/config', false], ['update', 'POST', '/config', true], ]; const MONITORING_SPEC = [ ['stats', 'GET', '/monitoring/stats', false], ['containerStats', 'GET', '/monitoring/stats/:containerId', false], ['history', 'GET', '/monitoring/history/:containerId', false], ['alertConfig', 'GET', '/monitoring/alerts/config', false], ['updateAlertConfig','POST', '/monitoring/alerts/config', true], ['alerts', 'GET', '/monitoring/alerts', false], ]; module.exports = { DashCaddyClient, DashCaddyError };