[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:
Krystie
2026-08-12 17:34:10 -07:00
parent 0bf4406253
commit 503de258b8
105 changed files with 14057 additions and 2632 deletions
+326
View File
@@ -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 };
+245
View File
@@ -0,0 +1,245 @@
/**
* DashCaddy API — TypeScript type definitions
*
* Generated from the DashCaddy OpenAPI spec (openapi.yaml, v1.15.0).
* These interfaces model the main resource types returned by the API.
*
* Response envelope:
* Success: { success: true, ...data }
* Error: { success: false, error: string, code?: string }
*/
// ── Response Envelope ──────────────────────────────────────────
/** Standard success envelope returned by all DashCaddy endpoints. */
export interface SuccessResponse<T = Record<string, unknown>> {
success: true;
/** Endpoint-specific payload fields (spread at top level). */
data?: T;
[key: string]: unknown;
}
/** Standard error envelope. */
export interface ErrorResponse {
success: false;
/** Human-readable error message (may include a DC error code). */
error: string;
/** Machine-readable error code, e.g. 'DC-CONT-002'. */
code?: string;
/** Extra context — e.g. { requiresTotp: true }. */
[key: string]: unknown;
}
/** Union type for any API response. */
export type ApiResponse<T = Record<string, unknown>> = SuccessResponse<T> | ErrorResponse;
// ── Service ────────────────────────────────────────────────────
/** A dashboard service registration (from services.json). */
export interface Service {
/** Unique service identifier. */
id: string;
/** Display name shown on the dashboard. */
name: string;
/** Service URL (full or relative, resolved via site config). */
url: string;
/** Icon path or URL. */
icon?: string;
/** Category for grouping. */
category?: string;
/** Whether health checking is enabled for this service. */
healthCheck?: boolean;
/** Subdomain mapping (optional). */
subdomain?: string;
/** Description (optional). */
description?: string;
}
/** Aggregated status entry for a single service probe. */
export interface ServiceStatus {
id: string;
isUp: boolean;
statusCode: number;
responseTime: number;
url?: string;
error?: string;
via?: string;
}
// ── Container ──────────────────────────────────────────────────
/** A discovered Docker container (sami.managed). */
export interface Container {
/** Container ID (Docker). */
id: string;
/** Container name (leading '/' stripped). */
name: string;
/** Image name and tag. */
image: string;
/** Docker state: running, exited, etc. */
state: string;
/** Human-readable status string from Docker. */
status: string;
/** App template name if deployed via DashCaddy. */
appTemplate?: string;
/** Subdomain if configured. */
subdomain?: string;
/** Port mappings. */
ports?: ContainerPort[];
}
/** Port mapping for a container. */
export interface ContainerPort {
IP?: string;
PrivatePort?: number;
PublicPort?: number;
Type?: string;
}
/** Resource usage stats for a container. */
export interface ContainerStats {
id: string;
name: string;
cpuPercent: number;
memoryUsage: number;
memoryLimit: number;
memoryPercent: number;
networkRx: number;
networkTx: number;
blockRead: number;
blockWrite: number;
}
// ── Health ─────────────────────────────────────────────────────
/** Health status for a single monitored service. */
export interface HealthStatus {
/** 'healthy' | 'unhealthy' | 'down' | 'unknown' | 'timeout' */
status: string;
/** HTTP status code if probed. */
statusCode?: number;
/** Response time in milliseconds. */
responseTime?: number;
/** Reason for the status (e.g. error message). */
reason?: string;
}
/** Liveness / readiness probe result. */
export interface HealthProbeResult {
status: 'ok' | 'error';
uptime?: number;
message?: string;
checks?: Record<string, boolean>;
}
// ── DNS ────────────────────────────────────────────────────────
/** A DNS record (universal — Technitium, Cloudflare, etc.). */
export interface DNSRecord {
/** Record type: A, AAAA, CNAME, MX, TXT, etc. */
type: string;
/** Domain / zone name. */
domain: string;
/** Record value / target. */
value?: string;
/** TTL in seconds. */
ttl?: number;
/** Priority (for MX/SRV). */
priority?: number;
/** Port (for SRV). */
port?: number;
/** Whether the record is enabled. */
enabled?: boolean;
}
/** DNS provider information. */
export interface DNSProvider {
id: string;
name: string;
type: string;
configured: boolean;
}
// ── Backup ─────────────────────────────────────────────────────
/** Backup system configuration. */
export interface BackupConfig {
/** List of per-app backup schedules. */
backups?: BackupSchedule[];
/** Default retention count. */
defaultRetention?: number;
}
/** A single app's backup schedule entry. */
export interface BackupSchedule {
appId: string;
enabled: boolean;
schedule: string;
retention: number;
}
/** A backup history entry. */
export interface BackupHistoryEntry {
id: string;
appId: string;
timestamp: string;
status: string;
size?: number;
file?: string;
}
// ── Config ─────────────────────────────────────────────────────
/** DashCaddy site configuration. */
export interface SiteConfig {
title?: string;
theme?: 'light' | 'dark' | 'auto';
logo?: string;
favicon?: string;
customCss?: string;
dnsServers?: Record<string, unknown>;
pylon?: { url?: string; key?: string };
[key: string]: unknown;
}
// ── Monitoring ─────────────────────────────────────────────────
/** Aggregated monitoring stats for all containers. */
export interface MonitoringStats {
[containerId: string]: {
name: string;
cpu: number;
memory: number;
memoryUsage: number;
};
}
/** Alert configuration for resource monitoring. */
export interface AlertConfig {
cpuThreshold?: number;
memoryThreshold?: number;
enabled?: boolean;
[key: string]: unknown;
}
// ── Client Options ─────────────────────────────────────────────
/** Options for constructing a DashCaddyClient. */
export interface DashCaddyClientOptions {
/** Base URL, e.g. 'https://status.sami'. */
baseUrl: string;
/** API key in format dk_<id>_<secret>. Bypasses CSRF. */
apiKey?: string;
/** Session cookie value for cookie-based auth. */
sessionCookie?: string;
/** CSRF token (auto-fetched if not provided and not using API key). */
csrfToken?: string;
/** Request timeout in ms (default 30000). */
timeout?: number;
/** Max retry attempts on 5xx (default 3). */
maxRetries?: number;
/** Extra headers to send with every request. */
headers?: Record<string, string>;
/** Custom fetch implementation (default global fetch). */
fetch?: typeof fetch;
}