fix: 2026-06-04 audit pass — README, USB pack, multi-area fixes

WS ticket dashboard auth, builder universal signing/size limits/fusion obfuscation/dropper bundles, Path Tracer WireGuard topology, SessionGate degraded mode and download timeouts, server bootstrap (data dir, cloudflared dedupe, config port precedence), agent mesh/miner/spread fixes. README refreshed; usb bundle repacked; PROBLEMS.md audit log updated.
This commit is contained in:
AetherForge
2026-06-04 20:41:44 -07:00
parent 6bfce5d5ab
commit 8466c7aa9b
101 changed files with 3369 additions and 1054 deletions

View File

@@ -2,26 +2,66 @@
* @vitest-environment happy-dom
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { authHeaders, clearStoredAuth, getStoredAuth, setStoredAuth } from '../api/auth';
import {
AETHERFORGE_CLIENT_HEADER,
AETHERFORGE_CLIENT_VALUE,
authHeaders,
clearStoredAuth,
consumeAuthExpiredFlag,
encodeBasicToken,
getStoredAuth,
setStoredAuth,
} from '../api/auth';
const AUTH_KEY = 'aetherforge_auth';
describe('auth session helpers', () => {
beforeEach(() => {
sessionStorage.clear();
localStorage.clear();
});
it('stores and retrieves basic token', () => {
it('stores and retrieves basic token in session and local storage', () => {
setStoredAuth('drjones', 'secret');
expect(getStoredAuth()).toBe(btoa('drjones:secret'));
const token = encodeBasicToken('drjones', 'secret');
expect(getStoredAuth()).toBe(token);
expect(sessionStorage.getItem(AUTH_KEY)).toBe(token);
expect(localStorage.getItem(AUTH_KEY)).toBe(token);
});
it('builds Authorization header when logged in', () => {
it('reads from localStorage when sessionStorage is empty', () => {
const token = encodeBasicToken('user', 'pass');
localStorage.setItem(AUTH_KEY, token);
expect(getStoredAuth()).toBe(token);
});
it('builds Authorization and client header when logged in', () => {
setStoredAuth('user', 'pass');
expect(authHeaders()).toEqual({ Authorization: `Basic ${btoa('user:pass')}` });
expect(authHeaders()).toEqual({
[AETHERFORGE_CLIENT_HEADER]: AETHERFORGE_CLIENT_VALUE,
Authorization: `Basic ${encodeBasicToken('user', 'pass')}`,
});
});
it('returns empty headers when logged out', () => {
it('includes client header when logged out', () => {
clearStoredAuth();
expect(authHeaders()).toEqual({});
expect(authHeaders()).toEqual({
[AETHERFORGE_CLIENT_HEADER]: AETHERFORGE_CLIENT_VALUE,
});
});
it('clears both storages on logout', () => {
setStoredAuth('user', 'pass');
clearStoredAuth();
expect(sessionStorage.getItem(AUTH_KEY)).toBeNull();
expect(localStorage.getItem(AUTH_KEY)).toBeNull();
expect(getStoredAuth()).toBeNull();
});
it('encodeBasicToken supports non-ASCII passwords', () => {
const token = encodeBasicToken('user', 'päss');
expect(token).toBeTruthy();
expect(token).not.toBe(btoa('user:päss'));
});
it('getStoredAuth returns null when sessionStorage throws', () => {
@@ -30,4 +70,11 @@ describe('auth session helpers', () => {
});
expect(getStoredAuth()).toBeNull();
});
it('consumeAuthExpiredFlag is set once on expired logout', () => {
setStoredAuth('user', 'pass');
clearStoredAuth({ expired: true });
expect(consumeAuthExpiredFlag()).toBe(true);
expect(consumeAuthExpiredFlag()).toBe(false);
});
});

View File

@@ -1,30 +1,105 @@
const AUTH_KEY = 'aetherforge_auth';
const AUTH_EXPIRED_KEY = 'aetherforge_auth_expired';
export function getStoredAuth(): string | null {
export const AETHERFORGE_CLIENT_HEADER = 'X-AetherForge-Client';
export const AETHERFORGE_CLIENT_VALUE = 'dashboard';
/** UTF-8-safe Basic auth token (username:password) for Authorization header. */
export function encodeBasicToken(username: string, password: string): string {
const bytes = new TextEncoder().encode(`${username}:${password}`);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
function readAuthStorage(): string | null {
try {
return sessionStorage.getItem(AUTH_KEY);
const session = sessionStorage.getItem(AUTH_KEY);
if (session) return session;
} catch {
/* sessionStorage blocked */
}
try {
return localStorage.getItem(AUTH_KEY);
} catch {
return null;
}
}
function writeAuthStorage(token: string) {
try {
sessionStorage.setItem(AUTH_KEY, token);
} catch {
/* ignore */
}
try {
localStorage.setItem(AUTH_KEY, token);
} catch {
/* ignore */
}
}
function removeAuthStorage() {
try {
sessionStorage.removeItem(AUTH_KEY);
} catch {
/* ignore */
}
try {
localStorage.removeItem(AUTH_KEY);
} catch {
/* ignore */
}
}
export function getStoredAuth(): string | null {
return readAuthStorage();
}
export function setStoredAuth(username: string, password: string, opts?: { silent?: boolean }) {
const token = btoa(`${username}:${password}`);
sessionStorage.setItem(AUTH_KEY, token);
const token = encodeBasicToken(username, password);
writeAuthStorage(token);
if (!opts?.silent) {
window.dispatchEvent(new Event('aetherforge-auth'));
}
}
export function clearStoredAuth(opts?: { silent?: boolean }) {
sessionStorage.removeItem(AUTH_KEY);
export function clearStoredAuth(opts?: { silent?: boolean; expired?: boolean }) {
if (opts?.expired) {
try {
sessionStorage.setItem(AUTH_EXPIRED_KEY, '1');
} catch {
/* ignore */
}
}
removeAuthStorage();
if (!opts?.silent) {
window.dispatchEvent(new Event('aetherforge-auth'));
}
}
/** True once after a 401 cleared stored credentials; consumed by SessionGate login UI. */
export function consumeAuthExpiredFlag(): boolean {
try {
if (sessionStorage.getItem(AUTH_EXPIRED_KEY)) {
sessionStorage.removeItem(AUTH_EXPIRED_KEY);
return true;
}
} catch {
/* ignore */
}
return false;
}
export function authHeaders(): Record<string, string> {
const headers: Record<string, string> = {
[AETHERFORGE_CLIENT_HEADER]: AETHERFORGE_CLIENT_VALUE,
};
const token = getStoredAuth();
if (!token) return {};
return { Authorization: `Basic ${token}` };
if (token) {
headers.Authorization = `Basic ${token}`;
}
return headers;
}

View File

@@ -22,6 +22,7 @@ describe('api client', () => {
beforeEach(() => {
sessionStorage.clear();
localStorage.clear();
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
});
@@ -38,6 +39,7 @@ describe('api client', () => {
function expectAuthHeaders(init: RequestInit) {
const headers = init.headers as Record<string, string>;
expect(headers.Authorization).toBe(`Basic ${btoa('user:pass')}`);
expect(headers['X-AetherForge-Client']).toBe('dashboard');
}
it('sends JSON Content-Type and auth on listAgents', async () => {
@@ -69,6 +71,16 @@ describe('api client', () => {
const headers = lastFetch().init.headers as Record<string, string>;
expect(headers.Authorization).toBeUndefined();
expect(headers['X-AetherForge-Client']).toBe('dashboard');
});
it('clears stored auth on 401 API response', async () => {
setStoredAuth('user', 'pass');
fetchMock.mockResolvedValueOnce(textResponse('Unauthorized', 401));
await expect(api.listAgents()).rejects.toThrow('API error 401');
expect(sessionStorage.getItem('aetherforge_auth')).toBeNull();
expect(localStorage.getItem('aetherforge_auth')).toBeNull();
});
it('getAgentStats appends limit query param', async () => {
@@ -111,6 +123,18 @@ describe('api client', () => {
expect(url).toBe('/api/v1/builder/build');
expect(init.method).toBe('POST');
expect(init.body).toBe(JSON.stringify(req));
expect(init.signal).toBeDefined();
});
it('buildAgent surfaces server error from JSON body', async () => {
const req = { fusion_enabled: false, wallet: '4' + 'A'.repeat(94) } as Parameters<typeof api.buildAgent>[0];
fetchMock.mockResolvedValueOnce({
ok: false,
status: 500,
text: async () => JSON.stringify({ success: false, error: 'compile failed (garble): OOM' }),
});
await expect(api.buildAgent(req)).rejects.toThrow('compile failed (garble): OOM');
});
it('buildAgent rejects fusion without prep file', async () => {

View File

@@ -1,11 +1,72 @@
import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, EarningsEstimate, FusionEstimate, XmrPrice, PathTraceHop } from '../types';
import { authHeaders } from './auth';
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 1030+ 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<BuildResponse> {
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<BuildResponse> {
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<string, string> | 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<T>(url: string, options?: RequestInit, timeoutMs = 10000): Promise<T> {
const { headers: extraHeaders, signal: callerSignal, ...rest } = options ?? {} as RequestInit & { signal?: AbortSignal };
const controller = new AbortController();
@@ -24,10 +85,18 @@ async function fetchJSON<T>(url: string, options?: RequestInit, timeoutMs = 1000
},
});
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);
}
@@ -65,20 +134,14 @@ export const api = {
const form = new FormData();
form.append('config', JSON.stringify(req));
form.append('prep_exe', prepFile, prepFile.name || 'prep.exe');
return fetch(`${API_BASE}/builder/build`, {
return postForgeBuild('/builder/build', {
method: 'POST',
headers: authHeaders(),
body: form,
}).then(async (res) => {
if (!res.ok) {
const err = await res.text();
throw new Error(`API error ${res.status}: ${err}`);
}
return res.json() as Promise<BuildResponse>;
});
}
return fetchJSON<BuildResponse>('/builder/build', {
return postForgeBuild('/builder/build', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
},
@@ -90,17 +153,31 @@ export const api = {
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,
}).then(async (res) => {
if (!res.ok) {
const err = await res.text();
throw new Error(`API error ${res.status}: ${err}`);
}
return res.json() as Promise<FusionEstimate>;
});
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<FusionEstimate>;
})
.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) =>
@@ -159,12 +236,17 @@ export const api = {
body: JSON.stringify(mac ? { mac } : {}),
}),
getAgentLog: (id: string, refresh = false) =>
fetchJSON<{ agent_id: string; content: string }>(`/agents/${id}/log${refresh ? '?refresh=1' : ''}`),
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<void> => {
const res = await fetch(`${API_BASE}/agents/${id}/log?download=1`, {
headers: { ...authHeaders() },
});
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);
@@ -236,9 +318,8 @@ export const api = {
// Full deck backup — downloads a zip containing config.json, users.json, miner.db.
downloadBackup: async (): Promise<void> => {
const res = await fetch(`${API_BASE}/backup`, {
const res = await fetchAuthedWithTimeout(`${API_BASE}/backup`, BACKUP_DOWNLOAD_TIMEOUT_MS, {
method: 'GET',
headers: { ...authHeaders() },
});
if (!res.ok) {
const err = await res.text();

View File

@@ -2,7 +2,7 @@
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { downloadAuthedFile, downloadApiFile } from './download';
import { downloadAuthedFile, downloadApiFile, fetchAuthedWithTimeout } from './download';
import { setStoredAuth } from './auth';
describe('downloadAuthedFile', () => {
@@ -11,6 +11,7 @@ describe('downloadAuthedFile', () => {
beforeEach(() => {
sessionStorage.clear();
localStorage.clear();
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
clickMock = vi.fn();
@@ -30,7 +31,9 @@ describe('downloadAuthedFile', () => {
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/v1/builds/b1/download');
expect((init.headers as Record<string, string>).Authorization).toBe(`Basic ${btoa('user:pass')}`);
const headers = init.headers as Record<string, string>;
expect(headers.Authorization).toBe(`Basic ${btoa('user:pass')}`);
expect(headers['X-AetherForge-Client']).toBe('dashboard');
expect(clickMock).toHaveBeenCalled();
});
@@ -65,4 +68,18 @@ describe('downloadAuthedFile', () => {
it('downloadApiFile is an alias', () => {
expect(downloadApiFile).toBe(downloadAuthedFile);
});
it('throws timeout message when download exceeds limit', async () => {
fetchMock.mockImplementation((_url, init?: RequestInit) => {
return new Promise((_, reject) => {
init?.signal?.addEventListener('abort', () => {
reject(new DOMException('The operation was aborted.', 'AbortError'));
});
});
});
await expect(fetchAuthedWithTimeout('/api/v1/builds/x/download', 1500)).rejects.toThrow(
'Download timed out after 2s',
);
});
});

View File

@@ -1,9 +1,46 @@
import { authHeaders } from './auth';
/** Large build artifacts (ZIP, fusion bundles). */
export const DOWNLOAD_TIMEOUT_MS = 5 * 60 * 1000;
/** Full deck backup zip — may include DB + config. */
export const BACKUP_DOWNLOAD_TIMEOUT_MS = 10 * 60 * 1000;
function downloadTimeoutError(timeoutMs: number): Error {
return new Error(`Download timed out after ${Math.round(timeoutMs / 1000)}s`);
}
/** Authenticated fetch with abort timeout and consistent AbortError messaging. */
export async function fetchAuthedWithTimeout(
url: string,
timeoutMs: number,
init?: RequestInit,
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, {
...init,
signal: controller.signal,
headers: {
...authHeaders(),
...(init?.headers as Record<string, string> | undefined),
},
});
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') {
throw downloadTimeoutError(timeoutMs);
}
throw e;
} finally {
clearTimeout(timer);
}
}
/** Download a protected /api/v1 file using session auth (build uninstall scripts, etc.). */
export async function downloadAuthedFile(apiPath: string, filename: string): Promise<void> {
const path = apiPath.startsWith('/api/v1') ? apiPath : `/api/v1${apiPath.startsWith('/') ? apiPath : `/${apiPath}`}`;
const res = await fetch(path, { headers: authHeaders() });
const res = await fetchAuthedWithTimeout(path, DOWNLOAD_TIMEOUT_MS);
if (!res.ok) {
const err = await res.text();
throw new Error(err || `Download failed (${res.status})`);