fix(webui): gate bootstrap API token issuance
This commit is contained in:
+5
-4
@@ -24,6 +24,7 @@ import { ThemeProvider, useTheme } from "@/hooks/useTheme";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
clearSavedSecret,
|
||||
consumeUrlBootstrapSecret,
|
||||
deriveWsUrl,
|
||||
fetchBootstrap,
|
||||
loadSavedSecret,
|
||||
@@ -361,14 +362,14 @@ export default function App() {
|
||||
current.status === "ready" && current.client === client
|
||||
? {
|
||||
...current,
|
||||
token: boot.token,
|
||||
token: boot.api_token,
|
||||
tokenExpiresAt,
|
||||
modelName: boot.model_name ?? current.modelName,
|
||||
runtimeSurface,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
return { token: boot.token, url };
|
||||
return { token: boot.api_token, url };
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -402,7 +403,7 @@ export default function App() {
|
||||
setState({
|
||||
status: "ready",
|
||||
client,
|
||||
token: boot.token,
|
||||
token: boot.api_token,
|
||||
tokenExpiresAt: bootstrapTokenExpiresAt(boot.expires_in),
|
||||
modelName: boot.model_name ?? null,
|
||||
runtimeSurface,
|
||||
@@ -441,7 +442,7 @@ export default function App() {
|
||||
}, [refreshReadyClient, state]);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = loadSavedSecret();
|
||||
const saved = consumeUrlBootstrapSecret() || loadSavedSecret();
|
||||
return bootstrapWithSecret(saved);
|
||||
}, [bootstrapWithSecret]);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { BootstrapResponse } from "./types";
|
||||
import { fetchWithTimeout } from "./http";
|
||||
|
||||
const SECRET_STORAGE_KEY = "nanobot-webui.bootstrap-secret";
|
||||
const URL_SECRET_PARAM = "bootstrapSecret";
|
||||
|
||||
/** Read a previously saved bootstrap secret from localStorage. */
|
||||
export function loadSavedSecret(): string {
|
||||
@@ -31,6 +32,29 @@ export function clearSavedSecret(): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function consumeUrlBootstrapSecret(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
const hash = window.location.hash || "";
|
||||
const queryStart = hash.indexOf("?");
|
||||
if (queryStart < 0) return "";
|
||||
|
||||
const path = hash.slice(0, queryStart) || "#/";
|
||||
const query = hash.slice(queryStart + 1);
|
||||
const params = new URLSearchParams(query);
|
||||
const secret = params.get(URL_SECRET_PARAM)?.trim() || "";
|
||||
if (!secret) return "";
|
||||
|
||||
params.delete(URL_SECRET_PARAM);
|
||||
const nextQuery = params.toString();
|
||||
const nextHash = `${path}${nextQuery ? `?${nextQuery}` : ""}`;
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${window.location.pathname}${window.location.search}${nextHash}`,
|
||||
);
|
||||
return secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a short-lived token + the WebSocket path from the gateway's
|
||||
* ``/webui/bootstrap`` endpoint.
|
||||
@@ -56,6 +80,9 @@ export async function fetchBootstrap(
|
||||
if (!body.token || !body.ws_path) {
|
||||
throw new Error("bootstrap response missing token or ws_path");
|
||||
}
|
||||
if (!body.api_token) {
|
||||
throw new Error("bootstrap response missing api_token");
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
|
||||
@@ -296,6 +296,7 @@ export interface SidebarStatePayload {
|
||||
|
||||
export interface BootstrapResponse {
|
||||
token: string;
|
||||
api_token: string;
|
||||
ws_path: string;
|
||||
ws_url?: string | null;
|
||||
expires_in: number;
|
||||
|
||||
@@ -177,10 +177,12 @@ vi.mock("@/hooks/useTheme", async () => {
|
||||
vi.mock("@/lib/bootstrap", () => ({
|
||||
fetchBootstrap: vi.fn().mockResolvedValue({
|
||||
token: "tok",
|
||||
api_token: "api-tok",
|
||||
ws_path: "/",
|
||||
expires_in: 300,
|
||||
}),
|
||||
deriveWsUrl: vi.fn(() => "ws://test"),
|
||||
consumeUrlBootstrapSecret: vi.fn(() => ""),
|
||||
loadSavedSecret: vi.fn(() => ""),
|
||||
saveSecret: vi.fn(),
|
||||
clearSavedSecret: vi.fn(),
|
||||
@@ -239,6 +241,7 @@ describe("App layout", () => {
|
||||
localStorage.removeItem("nanobot-webui.sidebar.session-updates.v1");
|
||||
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
|
||||
token: "tok",
|
||||
api_token: "api-tok",
|
||||
ws_path: "/",
|
||||
expires_in: 300,
|
||||
});
|
||||
@@ -723,6 +726,7 @@ describe("App layout", () => {
|
||||
];
|
||||
vi.mocked(fetchBootstrap).mockResolvedValue({
|
||||
token: "tok",
|
||||
api_token: "api-tok",
|
||||
ws_path: "/",
|
||||
expires_in: 300,
|
||||
runtime_surface: "native",
|
||||
@@ -2147,11 +2151,13 @@ describe("App layout", () => {
|
||||
vi.mocked(fetchBootstrap)
|
||||
.mockResolvedValueOnce({
|
||||
token: "tok-1",
|
||||
api_token: "api-tok-1",
|
||||
ws_path: "/",
|
||||
expires_in: 30,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
token: "tok-2",
|
||||
api_token: "api-tok-2",
|
||||
ws_path: "/",
|
||||
expires_in: 300,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap";
|
||||
import { consumeUrlBootstrapSecret, deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap";
|
||||
|
||||
describe("bootstrap helpers", () => {
|
||||
afterEach(() => {
|
||||
@@ -50,4 +50,29 @@ describe("bootstrap helpers", () => {
|
||||
|
||||
await pending;
|
||||
});
|
||||
|
||||
it("rejects bootstrap responses without an API token", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ token: "ws-token", ws_path: "/", expires_in: 300 }),
|
||||
})),
|
||||
);
|
||||
|
||||
await expect(fetchBootstrap()).rejects.toThrow(
|
||||
"bootstrap response missing api_token",
|
||||
);
|
||||
});
|
||||
|
||||
it("consumes bootstrap secrets from the URL fragment", () => {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
"/#/settings?bootstrapSecret=s3cret§ion=models",
|
||||
);
|
||||
|
||||
expect(consumeUrlBootstrapSecret()).toBe("s3cret");
|
||||
expect(window.location.hash).toBe("#/settings?section=models");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user