fix(webui): route missing API bootstrap tokens to auth

maintainer edit: handle review feedback by treating bootstrap responses without api_token as auth-required, and remove the obsolete issue_token(api_token=...) compatibility path now that API tokens are issued separately.
This commit is contained in:
chengyongru
2026-07-08 21:01:48 +08:00
committed by Xubin Ren
parent 444f488563
commit 4ddd639e67
6 changed files with 70 additions and 20 deletions
+13 -5
View File
@@ -23,6 +23,7 @@ import { useSkills } from "@/hooks/useSkills";
import { ThemeProvider, useTheme } from "@/hooks/useTheme";
import { cn } from "@/lib/utils";
import {
BootstrapAuthRequiredError,
clearSavedSecret,
consumeUrlBootstrapSecret,
deriveWsUrl,
@@ -296,6 +297,12 @@ function normalizeWorkspaceScope(scope: WorkspaceScopePayload): WorkspaceScopePa
};
}
function isBootstrapAuthRequired(error: unknown): boolean {
if (error instanceof BootstrapAuthRequiredError) return true;
const msg = error instanceof Error ? error.message : String(error);
return msg.includes("HTTP 401") || msg.includes("HTTP 403");
}
function HostChrome({
onToggleSidebar,
onSidebarPreviewEnter,
@@ -410,11 +417,13 @@ export default function App() {
});
} catch (e) {
if (cancelled) return;
const msg = (e as Error).message;
if (msg.includes("HTTP 401") || msg.includes("HTTP 403")) {
if (isBootstrapAuthRequired(e)) {
setState({ status: "auth", failed: !!secret });
} else {
setState({ status: "error", message: msg });
setState({
status: "error",
message: e instanceof Error ? e.message : String(e),
});
}
}
})();
@@ -432,8 +441,7 @@ export default function App() {
try {
await refreshReadyClient(client, state.runtimeSurface);
} catch (e) {
const msg = (e as Error).message;
if (msg.includes("HTTP 401") || msg.includes("HTTP 403")) {
if (isBootstrapAuthRequired(e)) {
setState({ status: "auth", failed: !!bootstrapSecretRef.current });
}
}
+13 -1
View File
@@ -4,6 +4,13 @@ import { fetchWithTimeout } from "./http";
const SECRET_STORAGE_KEY = "nanobot-webui.bootstrap-secret";
const URL_SECRET_PARAM = "bootstrapSecret";
export class BootstrapAuthRequiredError extends Error {
constructor(message = "bootstrap authentication required") {
super(message);
this.name = "BootstrapAuthRequiredError";
}
}
/** Read a previously saved bootstrap secret from localStorage. */
export function loadSavedSecret(): string {
if (typeof window === "undefined") return "";
@@ -74,6 +81,9 @@ export async function fetchBootstrap(
headers,
}, timeoutMs);
if (!res.ok) {
if (res.status === 401 || res.status === 403) {
throw new BootstrapAuthRequiredError(`bootstrap failed: HTTP ${res.status}`);
}
throw new Error(`bootstrap failed: HTTP ${res.status}`);
}
const body = (await res.json()) as BootstrapResponse;
@@ -81,7 +91,9 @@ export async function fetchBootstrap(
throw new Error("bootstrap response missing token or ws_path");
}
if (!body.api_token) {
throw new Error("bootstrap response missing api_token");
throw new BootstrapAuthRequiredError(
"bootstrap authentication required: missing api_token",
);
}
return body;
}
+25 -1
View File
@@ -175,6 +175,12 @@ vi.mock("@/hooks/useTheme", async () => {
});
vi.mock("@/lib/bootstrap", () => ({
BootstrapAuthRequiredError: class BootstrapAuthRequiredError extends Error {
constructor(message = "bootstrap authentication required") {
super(message);
this.name = "BootstrapAuthRequiredError";
}
},
fetchBootstrap: vi.fn().mockResolvedValue({
token: "tok",
api_token: "api-tok",
@@ -217,7 +223,11 @@ vi.mock("@/lib/nanobot-client", () => {
return { NanobotClient: MockClient };
});
import { deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap";
import {
BootstrapAuthRequiredError,
deriveWsUrl,
fetchBootstrap,
} from "@/lib/bootstrap";
import App from "@/App";
describe("App layout", () => {
@@ -271,6 +281,20 @@ describe("App layout", () => {
expect(connectSpy).not.toHaveBeenCalled();
});
it("shows the auth form when bootstrap does not issue an API token", async () => {
vi.mocked(fetchBootstrap).mockRejectedValueOnce(
new BootstrapAuthRequiredError(
"bootstrap authentication required: missing api_token",
),
);
render(<App />);
expect(await screen.findByText("Authentication required")).toBeInTheDocument();
expect(screen.queryByText("Invalid password. Try again.")).not.toBeInTheDocument();
expect(connectSpy).not.toHaveBeenCalled();
});
it("shows an invalid-password error after a submitted password is rejected", async () => {
vi.mocked(fetchBootstrap).mockRejectedValue(
new Error("bootstrap failed: HTTP 401"),
+13 -5
View File
@@ -1,6 +1,11 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { consumeUrlBootstrapSecret, deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap";
import {
BootstrapAuthRequiredError,
consumeUrlBootstrapSecret,
deriveWsUrl,
fetchBootstrap,
} from "@/lib/bootstrap";
describe("bootstrap helpers", () => {
afterEach(() => {
@@ -51,7 +56,7 @@ describe("bootstrap helpers", () => {
await pending;
});
it("rejects bootstrap responses without an API token", async () => {
it("treats bootstrap responses without an API token as auth-required", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
@@ -60,9 +65,12 @@ describe("bootstrap helpers", () => {
})),
);
await expect(fetchBootstrap()).rejects.toThrow(
"bootstrap response missing api_token",
);
const promise = fetchBootstrap();
await expect(promise).rejects.toMatchObject({
name: "BootstrapAuthRequiredError",
message: "bootstrap authentication required: missing api_token",
});
await expect(promise).rejects.toBeInstanceOf(BootstrapAuthRequiredError);
});
it("consumes bootstrap secrets from the URL fragment", () => {