From 268b85a639aa19549543ebea10e621320a0c11f6 Mon Sep 17 00:00:00 2001 From: Petar Date: Mon, 24 Aug 2026 14:34:06 +0200 Subject: [PATCH] fix(dashboard): send X-WB-Token on all frontend API calls (fixes 401) --- wallbreaker/dashboard/web/src/api.ts | 9 ++++-- wallbreaker/dashboard/web/src/auth.ts | 40 +++++++++++++++++++++++++ wallbreaker/dashboard/web/src/v2/api.ts | 5 ++-- 3 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 wallbreaker/dashboard/web/src/auth.ts diff --git a/wallbreaker/dashboard/web/src/api.ts b/wallbreaker/dashboard/web/src/api.ts index f768e9b..c283a84 100644 --- a/wallbreaker/dashboard/web/src/api.ts +++ b/wallbreaker/dashboard/web/src/api.ts @@ -1,3 +1,6 @@ +import { withAuth } from "./auth"; + + export interface ConfigInfo { has_target: boolean; target: string | null; @@ -229,7 +232,7 @@ export interface FireResult extends ComposeResult { } async function j(url: string, init?: RequestInit): Promise { - const r = await fetch(url, init); + const r = await fetch(url, await withAuth(init)); if (!r.ok) { let detail = r.statusText; try { @@ -328,12 +331,12 @@ export async function runAgent( onEvent: (ev: AgentEvent) => void, signal?: AbortSignal ): Promise { - const r = await fetch("/api/agent/run", { + const r = await fetch("/api/agent/run", await withAuth({ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), signal, - }); + })); if (!r.ok || !r.body) { let detail = r.statusText; try { detail = (await r.json()).detail || detail; } catch { /* ignore */ } diff --git a/wallbreaker/dashboard/web/src/auth.ts b/wallbreaker/dashboard/web/src/auth.ts new file mode 100644 index 0000000..1386fff --- /dev/null +++ b/wallbreaker/dashboard/web/src/auth.ts @@ -0,0 +1,40 @@ +// Shared dashboard authentication helper. +// +// The backend (dashboard/auth.py) requires an `X-WB-Token` header on every /api/* route +// except the exempt bootstrap paths (/api/health, /api/session). The token is minted per +// launch and exposed to the same-origin SPA via GET /api/session. This module fetches it +// once, caches the in-flight promise, and attaches it to outgoing requests via `withAuth`. +// +// Both the root API client (src/api.ts) and the V2 client (src/v2/api.ts) import from here +// so the whole app shares ONE token cache and one source of truth for auth. + +let tokenPromise: Promise | null = null; + +/** Fetch the per-launch dashboard token once and cache the promise. */ +export async function ensureToken(): Promise { + if (!tokenPromise) { + tokenPromise = fetch("/api/session") + .then((r) => (r.ok ? r.json() : { token: "" })) + .then((b: { token?: string }) => b.token ?? "") + .catch(() => ""); + } + + return tokenPromise; +} + +/** Return a RequestInit with the X-WB-Token header set (merging any existing headers). */ +export async function withAuth(init?: RequestInit): Promise { + const token = await ensureToken(); + + if (!token) { + return init ?? {}; + } + + const headers = new Headers(init?.headers); + headers.set("X-WB-Token", token); + + return { + ...init, + headers, + }; +} diff --git a/wallbreaker/dashboard/web/src/v2/api.ts b/wallbreaker/dashboard/web/src/v2/api.ts index 61dde16..b670a04 100644 --- a/wallbreaker/dashboard/web/src/v2/api.ts +++ b/wallbreaker/dashboard/web/src/v2/api.ts @@ -16,6 +16,7 @@ import type { SettingsRecord, } from "./types"; import { inferEventActor } from "./eventProjection"; +import { withAuth } from "../auth"; class HttpError extends Error { constructor(public status: number, message: string) { @@ -24,7 +25,7 @@ class HttpError extends Error { } async function request(url: string, init?: RequestInit): Promise { - const response = await fetch(url, init); + const response = await fetch(url, await withAuth(init)); if (!response.ok) { let message = response.statusText || `Request failed (${response.status})`; try { @@ -231,7 +232,7 @@ export const v2Api = { signal: AbortSignal, ): Promise { const url = `/api/v2/executions/${encodeURIComponent(executionId)}/events?after=${after}`; - const response = await fetch(url, { signal, headers: { Accept: "text/event-stream" } }); + const response = await fetch(url, await withAuth({ signal, headers: { Accept: "text/event-stream" } })); if (!response.ok || !response.body) throw new HttpError(response.status, response.statusText); const reader = response.body.getReader(); const decoder = new TextDecoder();