mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-19 16:12:30 +00:00
fix(webui): require token_issue_secret for LAN access with frontend auth
When host is set to 0.0.0.0, the gateway now enforces that either token or token_issue_secret must be configured — it refuses to start otherwise. Bootstrap endpoint behavior: - token_issue_secret configured: always validate regardless of source IP (handles reverse-proxy scenarios where all connections appear as localhost) - No secret: only localhost can bootstrap (local dev mode) The frontend shows an authentication form when bootstrap returns 401/403, persists the secret in localStorage, and retries automatically on reload.
This commit is contained in:
parent
034bea1a44
commit
4efd904ccc
@ -128,6 +128,17 @@ class WebSocketConfig(Base):
|
|||||||
raise ValueError("token_issue_path must differ from path (the WebSocket upgrade path)")
|
raise ValueError("token_issue_path must differ from path (the WebSocket upgrade path)")
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def wildcard_host_requires_auth(self) -> Self:
|
||||||
|
if self.host not in ("0.0.0.0", "::"):
|
||||||
|
return self
|
||||||
|
if self.token.strip() or self.token_issue_secret.strip():
|
||||||
|
return self
|
||||||
|
raise ValueError(
|
||||||
|
"host is 0.0.0.0 (all interfaces) but neither token nor "
|
||||||
|
"token_issue_secret is set — set one to prevent unauthenticated access"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _http_json_response(data: dict[str, Any], *, status: int = 200) -> Response:
|
def _http_json_response(data: dict[str, Any], *, status: int = 200) -> Response:
|
||||||
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
||||||
@ -607,10 +618,10 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._api_tokens.pop(token_key, None)
|
self._api_tokens.pop(token_key, None)
|
||||||
|
|
||||||
def _handle_webui_bootstrap(self, connection: Any, request: Any) -> Response:
|
def _handle_webui_bootstrap(self, connection: Any, request: Any) -> Response:
|
||||||
# When token_issue_secret is configured, validate it regardless of
|
# When a secret is configured (token_issue_secret or static token),
|
||||||
# source IP. This secures deployments behind a reverse proxy (e.g.
|
# validate it regardless of source IP. This secures deployments
|
||||||
# nginx) where all connections appear as localhost.
|
# behind a reverse proxy where all connections appear as localhost.
|
||||||
secret = self.config.token_issue_secret.strip()
|
secret = self.config.token_issue_secret.strip() or self.config.token.strip()
|
||||||
if secret:
|
if secret:
|
||||||
if not _issue_route_secret_matches(request.headers, secret):
|
if not _issue_route_secret_matches(request.headers, secret):
|
||||||
return _http_error(401, "Unauthorized")
|
return _http_error(401, "Unauthorized")
|
||||||
|
|||||||
@ -405,13 +405,52 @@ _LOCAL = _FakeConn(("127.0.0.1", 12345))
|
|||||||
_NO_HEADERS = _FakeReq()
|
_NO_HEADERS = _FakeReq()
|
||||||
|
|
||||||
|
|
||||||
def test_bootstrap_rejects_non_localhost_without_secret(bus: MagicMock) -> None:
|
def test_wildcard_host_without_auth_raises_on_startup(bus: MagicMock) -> None:
|
||||||
channel = _ch(bus, host="0.0.0.0")
|
import pytest
|
||||||
resp = channel._handle_webui_bootstrap(_REMOTE, _NO_HEADERS)
|
from pydantic_core import ValidationError
|
||||||
assert resp.status_code == 403
|
|
||||||
|
with pytest.raises(ValidationError, match="token"):
|
||||||
|
_ch(bus, host="0.0.0.0")
|
||||||
|
|
||||||
|
|
||||||
def test_bootstrap_allows_localhost_without_secret(bus: MagicMock) -> None:
|
def test_wildcard_host_with_token_is_valid(bus: MagicMock) -> None:
|
||||||
|
channel = _ch(bus, host="0.0.0.0", token="my-token")
|
||||||
|
assert channel.config.host == "0.0.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_wildcard_host_with_secret_is_valid(bus: MagicMock) -> None:
|
||||||
|
channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="s3cret")
|
||||||
|
assert channel.config.host == "0.0.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_wildcard_ipv6_without_auth_raises(bus: MagicMock) -> None:
|
||||||
|
import pytest
|
||||||
|
from pydantic_core import ValidationError
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError, match="token"):
|
||||||
|
_ch(bus, host="::")
|
||||||
|
|
||||||
|
|
||||||
|
def test_wildcard_ipv6_with_secret_is_valid(bus: MagicMock) -> None:
|
||||||
|
channel = _ch(bus, host="::", tokenIssueSecret="s3cret")
|
||||||
|
resp = channel._handle_webui_bootstrap(
|
||||||
|
_REMOTE, _FakeReq({"X-Nanobot-Auth": "s3cret"})
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_accepts_static_token_as_secret(bus: MagicMock) -> None:
|
||||||
|
"""When only token (not token_issue_secret) is set, bootstrap accepts it."""
|
||||||
|
channel = _ch(bus, host="0.0.0.0", token="static-tok")
|
||||||
|
resp = channel._handle_webui_bootstrap(
|
||||||
|
_REMOTE, _FakeReq({"Authorization": "Bearer static-tok"})
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = json.loads(resp.body)
|
||||||
|
assert body["token"].startswith("nbwt_")
|
||||||
|
|
||||||
|
|
||||||
|
def test_localhost_without_auth_is_valid(bus: MagicMock) -> None:
|
||||||
channel = _ch(bus, host="127.0.0.1")
|
channel = _ch(bus, host="127.0.0.1")
|
||||||
resp = channel._handle_webui_bootstrap(_LOCAL, _NO_HEADERS)
|
resp = channel._handle_webui_bootstrap(_LOCAL, _NO_HEADERS)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|||||||
@ -74,7 +74,7 @@ NANOBOT_API_URL=http://127.0.0.1:9000 bun run dev
|
|||||||
|
|
||||||
### Access from another device (LAN)
|
### Access from another device (LAN)
|
||||||
|
|
||||||
To use the webui from another device on the same network, set `host` to `"0.0.0.0"` and configure `token_issue_secret` in `~/.nanobot/config.json`:
|
To use the webui from another device on the same network, set `host` to `"0.0.0.0"` and configure a `token` or `tokenIssueSecret` in `~/.nanobot/config.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@ -89,9 +89,9 @@ To use the webui from another device on the same network, set `host` to `"0.0.0.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open `http://<your-ip>:8765` on the other device. The bootstrap endpoint requires the secret via the `Authorization: Bearer <secret>` header (or `X-Nanobot-Auth`). Without a configured secret, only localhost connections can bootstrap.
|
The gateway will refuse to start if `host` is `"0.0.0.0"` and neither `token` nor `tokenIssueSecret` is set.
|
||||||
|
|
||||||
> **Note:** This exposes the gateway to all interfaces. Always set `tokenIssueSecret` on non-local networks.
|
Then open `http://<your-ip>:8765` on the other device. The webui will show an authentication form where you enter the secret. It is saved in your browser so you only need to enter it once.
|
||||||
|
|
||||||
## Build for packaged runtime
|
## Build for packaged runtime
|
||||||
|
|
||||||
|
|||||||
@ -9,14 +9,23 @@ import { preloadMarkdownText } from "@/components/MarkdownText";
|
|||||||
import { useSessions } from "@/hooks/useSessions";
|
import { useSessions } from "@/hooks/useSessions";
|
||||||
import { useTheme } from "@/hooks/useTheme";
|
import { useTheme } from "@/hooks/useTheme";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap";
|
import {
|
||||||
|
clearSavedSecret,
|
||||||
|
deriveWsUrl,
|
||||||
|
fetchBootstrap,
|
||||||
|
loadSavedSecret,
|
||||||
|
saveSecret,
|
||||||
|
} from "@/lib/bootstrap";
|
||||||
import { NanobotClient } from "@/lib/nanobot-client";
|
import { NanobotClient } from "@/lib/nanobot-client";
|
||||||
import { ClientProvider } from "@/providers/ClientProvider";
|
import { ClientProvider } from "@/providers/ClientProvider";
|
||||||
import type { ChatSummary } from "@/lib/types";
|
import type { ChatSummary } from "@/lib/types";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
type BootState =
|
type BootState =
|
||||||
| { status: "loading" }
|
| { status: "loading" }
|
||||||
| { status: "error"; message: string }
|
| { status: "error"; message: string }
|
||||||
|
| { status: "auth"; failed?: boolean }
|
||||||
| {
|
| {
|
||||||
status: "ready";
|
status: "ready";
|
||||||
client: NanobotClient;
|
client: NanobotClient;
|
||||||
@ -28,6 +37,60 @@ const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
|
|||||||
const SIDEBAR_WIDTH = 272;
|
const SIDEBAR_WIDTH = 272;
|
||||||
type ShellView = "chat" | "settings";
|
type ShellView = "chat" | "settings";
|
||||||
|
|
||||||
|
function AuthForm({
|
||||||
|
failed,
|
||||||
|
onSecret,
|
||||||
|
}: {
|
||||||
|
failed: boolean;
|
||||||
|
onSecret: (secret: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [value, setValue] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const secret = value.trim();
|
||||||
|
if (!secret) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
onSecret(secret);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center px-6">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="flex w-full max-w-sm flex-col gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center gap-1 text-center">
|
||||||
|
<p className="text-lg font-semibold">{t("app.auth.title")}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{t("app.auth.hint")}</p>
|
||||||
|
</div>
|
||||||
|
{failed && (
|
||||||
|
<p className="text-center text-sm text-destructive">
|
||||||
|
{t("app.auth.invalid")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder={t("app.auth.placeholder")}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
disabled={submitting}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={!value.trim() || submitting}
|
||||||
|
>
|
||||||
|
{t("app.auth.submit")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function readSidebarOpen(): boolean {
|
function readSidebarOpen(): boolean {
|
||||||
if (typeof window === "undefined") return true;
|
if (typeof window === "undefined") return true;
|
||||||
try {
|
try {
|
||||||
@ -43,40 +106,55 @@ export default function App() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [state, setState] = useState<BootState>({ status: "loading" });
|
const [state, setState] = useState<BootState>({ status: "loading" });
|
||||||
|
|
||||||
|
const bootstrapWithSecret = useCallback(
|
||||||
|
(secret: string) => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
setState({ status: "loading" });
|
||||||
|
try {
|
||||||
|
const boot = await fetchBootstrap("", secret);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (secret) saveSecret(secret);
|
||||||
|
const url = deriveWsUrl(boot.ws_path, boot.token);
|
||||||
|
const client = new NanobotClient({
|
||||||
|
url,
|
||||||
|
onReauth: async () => {
|
||||||
|
try {
|
||||||
|
const refreshed = await fetchBootstrap("", secret);
|
||||||
|
return deriveWsUrl(refreshed.ws_path, refreshed.token);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
client.connect();
|
||||||
|
setState({
|
||||||
|
status: "ready",
|
||||||
|
client,
|
||||||
|
token: boot.token,
|
||||||
|
modelName: boot.model_name ?? null,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (cancelled) return;
|
||||||
|
const msg = (e as Error).message;
|
||||||
|
if (msg.includes("HTTP 401") || msg.includes("HTTP 403")) {
|
||||||
|
setState({ status: "auth", failed: true });
|
||||||
|
} else {
|
||||||
|
setState({ status: "error", message: msg });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
const saved = loadSavedSecret();
|
||||||
(async () => {
|
return bootstrapWithSecret(saved);
|
||||||
try {
|
}, [bootstrapWithSecret]);
|
||||||
const boot = await fetchBootstrap();
|
|
||||||
if (cancelled) return;
|
|
||||||
const url = deriveWsUrl(boot.ws_path, boot.token);
|
|
||||||
const client = new NanobotClient({
|
|
||||||
url,
|
|
||||||
onReauth: async () => {
|
|
||||||
try {
|
|
||||||
const refreshed = await fetchBootstrap();
|
|
||||||
return deriveWsUrl(refreshed.ws_path, refreshed.token);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
client.connect();
|
|
||||||
setState({
|
|
||||||
status: "ready",
|
|
||||||
client,
|
|
||||||
token: boot.token,
|
|
||||||
modelName: boot.model_name ?? null,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
if (cancelled) return;
|
|
||||||
setState({ status: "error", message: (e as Error).message });
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const warm = () => preloadMarkdownText();
|
const warm = () => preloadMarkdownText();
|
||||||
@ -110,6 +188,14 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (state.status === "auth") {
|
||||||
|
return (
|
||||||
|
<AuthForm
|
||||||
|
failed={!!state.failed}
|
||||||
|
onSecret={(s) => bootstrapWithSecret(s)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (state.status === "error") {
|
if (state.status === "error") {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center px-4 text-center">
|
<div className="flex h-full w-full items-center justify-center px-4 text-center">
|
||||||
@ -130,18 +216,26 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
if (state.status === "ready") {
|
||||||
|
state.client.close();
|
||||||
|
}
|
||||||
|
clearSavedSecret();
|
||||||
|
setState({ status: "auth" });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ClientProvider
|
<ClientProvider
|
||||||
client={state.client}
|
client={state.client}
|
||||||
token={state.token}
|
token={state.token}
|
||||||
modelName={state.modelName}
|
modelName={state.modelName}
|
||||||
>
|
>
|
||||||
<Shell onModelNameChange={handleModelNameChange} />
|
<Shell onModelNameChange={handleModelNameChange} onLogout={handleLogout} />
|
||||||
</ClientProvider>
|
</ClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | null) => void }) {
|
function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName: string | null) => void; onLogout: () => void }) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { theme, toggle } = useTheme();
|
const { theme, toggle } = useTheme();
|
||||||
const { sessions, loading, refresh, createChat, deleteChat } = useSessions();
|
const { sessions, loading, refresh, createChat, deleteChat } = useSessions();
|
||||||
@ -319,6 +413,7 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
|
|||||||
onToggleTheme={toggle}
|
onToggleTheme={toggle}
|
||||||
onBackToChat={() => setView("chat")}
|
onBackToChat={() => setView("chat")}
|
||||||
onModelNameChange={onModelNameChange}
|
onModelNameChange={onModelNameChange}
|
||||||
|
onLogout={onLogout}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ThreadShell
|
<ThreadShell
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { ChevronLeft, Loader2 } from "lucide-react";
|
import { ChevronLeft, Loader2 } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@ -14,11 +15,13 @@ interface SettingsViewProps {
|
|||||||
onToggleTheme: () => void;
|
onToggleTheme: () => void;
|
||||||
onBackToChat: () => void;
|
onBackToChat: () => void;
|
||||||
onModelNameChange: (modelName: string | null) => void;
|
onModelNameChange: (modelName: string | null) => void;
|
||||||
|
onLogout?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsView({
|
export function SettingsView({
|
||||||
onBackToChat,
|
onBackToChat,
|
||||||
onModelNameChange,
|
onModelNameChange,
|
||||||
|
onLogout,
|
||||||
}: SettingsViewProps) {
|
}: SettingsViewProps) {
|
||||||
const { token } = useClient();
|
const { token } = useClient();
|
||||||
const [settings, setSettings] = useState<SettingsPayload | null>(null);
|
const [settings, setSettings] = useState<SettingsPayload | null>(null);
|
||||||
@ -115,6 +118,7 @@ export function SettingsView({
|
|||||||
dirty={dirty}
|
dirty={dirty}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
onSave={save}
|
onSave={save}
|
||||||
|
onLogout={onLogout}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</main>
|
</main>
|
||||||
@ -129,6 +133,7 @@ function SettingsSection({
|
|||||||
dirty,
|
dirty,
|
||||||
saving,
|
saving,
|
||||||
onSave,
|
onSave,
|
||||||
|
onLogout,
|
||||||
}: {
|
}: {
|
||||||
form: {
|
form: {
|
||||||
model: string;
|
model: string;
|
||||||
@ -142,7 +147,9 @@ function SettingsSection({
|
|||||||
dirty: boolean;
|
dirty: boolean;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
|
onLogout?: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="space-y-7">
|
<div className="space-y-7">
|
||||||
<section>
|
<section>
|
||||||
@ -192,6 +199,19 @@ function SettingsSection({
|
|||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{onLogout && (
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-2 px-2 text-xs font-medium text-muted-foreground">{t("app.account.section")}</h2>
|
||||||
|
<SettingsGroup>
|
||||||
|
<SettingsRow title={t("app.account.logoutHint")}>
|
||||||
|
<Button size="sm" variant="outline" onClick={onLogout}>
|
||||||
|
{t("app.account.logout")}
|
||||||
|
</Button>
|
||||||
|
</SettingsRow>
|
||||||
|
</SettingsGroup>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,18 @@
|
|||||||
"title": "Couldn't reach nanobot",
|
"title": "Couldn't reach nanobot",
|
||||||
"gatewayHint": "Make sure the gateway is running (`nanobot gateway`) and that this page is open on the same machine."
|
"gatewayHint": "Make sure the gateway is running (`nanobot gateway`) and that this page is open on the same machine."
|
||||||
},
|
},
|
||||||
|
"auth": {
|
||||||
|
"title": "Authentication required",
|
||||||
|
"hint": "Enter the secret configured as tokenIssueSecret in your gateway config.",
|
||||||
|
"placeholder": "Password",
|
||||||
|
"submit": "Connect",
|
||||||
|
"invalid": "Invalid password. Try again."
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"section": "Account",
|
||||||
|
"logoutHint": "Disconnect this browser from the gateway.",
|
||||||
|
"logout": "Sign out"
|
||||||
|
},
|
||||||
"documentTitle": {
|
"documentTitle": {
|
||||||
"base": "nanobot",
|
"base": "nanobot",
|
||||||
"chat": "{{title}} · nanobot"
|
"chat": "{{title}} · nanobot"
|
||||||
|
|||||||
@ -1,15 +1,51 @@
|
|||||||
import type { BootstrapResponse } from "./types";
|
import type { BootstrapResponse } from "./types";
|
||||||
|
|
||||||
|
const SECRET_STORAGE_KEY = "nanobot-webui.bootstrap-secret";
|
||||||
|
|
||||||
|
/** Read a previously saved bootstrap secret from localStorage. */
|
||||||
|
export function loadSavedSecret(): string {
|
||||||
|
if (typeof window === "undefined") return "";
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(SECRET_STORAGE_KEY) ?? "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist the bootstrap secret so page reloads don't re-prompt. */
|
||||||
|
export function saveSecret(secret: string): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(SECRET_STORAGE_KEY, secret);
|
||||||
|
} catch {
|
||||||
|
// ignore storage errors (private mode, etc.)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear the saved bootstrap secret (sign out). */
|
||||||
|
export function clearSavedSecret(): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(SECRET_STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch a short-lived token + the WebSocket path from the gateway's
|
* Fetch a short-lived token + the WebSocket path from the gateway's
|
||||||
* ``/webui/bootstrap`` endpoint. Localhost-only on the server side.
|
* ``/webui/bootstrap`` endpoint.
|
||||||
*/
|
*/
|
||||||
export async function fetchBootstrap(
|
export async function fetchBootstrap(
|
||||||
baseUrl: string = "",
|
baseUrl: string = "",
|
||||||
|
secret: string = "",
|
||||||
): Promise<BootstrapResponse> {
|
): Promise<BootstrapResponse> {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (secret) {
|
||||||
|
headers["X-Nanobot-Auth"] = secret;
|
||||||
|
}
|
||||||
const res = await fetch(`${baseUrl}/webui/bootstrap`, {
|
const res = await fetch(`${baseUrl}/webui/bootstrap`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
|
headers,
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`bootstrap failed: HTTP ${res.status}`);
|
throw new Error(`bootstrap failed: HTTP ${res.status}`);
|
||||||
|
|||||||
@ -46,6 +46,9 @@ vi.mock("@/lib/bootstrap", () => ({
|
|||||||
expires_in: 300,
|
expires_in: 300,
|
||||||
}),
|
}),
|
||||||
deriveWsUrl: vi.fn(() => "ws://test"),
|
deriveWsUrl: vi.fn(() => "ws://test"),
|
||||||
|
loadSavedSecret: vi.fn(() => ""),
|
||||||
|
saveSecret: vi.fn(),
|
||||||
|
clearSavedSecret: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/nanobot-client", () => {
|
vi.mock("@/lib/nanobot-client", () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user