[grade=pending] QA sprint: commit 103 at-risk files from multi-agent sprint work
Committed by Hermes autonomous QA sprint 2026-08-13. These files were modified during the Aug 12 sprint but never committed.
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* 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_<id>_<secret>). 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<string,string>} [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<string|null>}
|
||||
*/
|
||||
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<object>} 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 };
|
||||
Reference in New Issue
Block a user