diff --git a/README.md b/README.md
index 384856423..ab567b5f3 100644
--- a/README.md
+++ b/README.md
@@ -411,6 +411,14 @@ is opt-in: it fails fast when `frontend/.next` has no completed build.
Gateway owns `/api/langgraph/*` and translates those public LangGraph-compatible paths to its native `/api/*` routers behind nginx.
+For a read-only demo without the Gateway, run `make build-static` from `frontend/`,
+then `HOSTNAME=127.0.0.1 PORT=3000 node --env-file=.env .next/standalone/server.js`
+from the same directory. The build includes public demo assets and resolves
+supported demo API reads locally; writes are unavailable. To display the homepage
+GitHub star count, set `GITHUB_OAUTH_TOKEN` in `frontend/.env` before starting Node.
+The token stays on the server; missing credentials or GitHub failures hide the
+count. Restart Node after changing the token; no rebuild is needed.
+
#### LangGraph Studio (Optional)
The default `make dev` topology uses DeerFlow's Gateway-embedded runtime and
diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md
index 721c0ac99..3ef4c6bb7 100644
--- a/frontend/AGENTS.md
+++ b/frontend/AGENTS.md
@@ -86,6 +86,16 @@ NEXT_PUBLIC_LANGGRAPH_BASE_URL=http://localhost:8001/api
Leave these unset for the standard `make dev` / Docker flow, where nginx serves the public `/api/langgraph/*` prefix and rewrites it to Gateway's native `/api/*` routes.
+`make build-static` creates a standalone read-only demo and copies `.next/static`
+and `public` into the output. In static mode, `core/api/static-response.ts`
+resolves Gateway REST reads with empty capability/catalog responses or existing
+same-origin `/mock/api` fixtures; writes and unknown API routes fail locally.
+The homepage client counter calls `/github-stars`, outside the Gateway proxy.
+That dynamic route reads the server-only `GITHUB_OAUTH_TOKEN` at runtime, caches
+GitHub data for one hour, and returns 204 when the count is unavailable. Start
+the standalone server from `frontend/` with `node --env-file=.env
+.next/standalone/server.js` to load the current credentials.
+
To reach a dev server on anything other than localhost — a LAN address, or a proxied hostname — list the host in `DEER_FLOW_DEV_ALLOWED_ORIGINS` (comma-separated; a full URL is reduced to its host). It feeds Next's `allowedDevOrigins`, which gates `/_next/*`, fonts, and HMR. Without it those requests get a 403 and the page renders server-side but never hydrates, so nothing on it — including the login form — responds. Development only; production builds ignore it.
## Resources
diff --git a/frontend/Makefile b/frontend/Makefile
index 862806bd5..2c1d8087c 100644
--- a/frontend/Makefile
+++ b/frontend/Makefile
@@ -30,3 +30,4 @@ format:
build-static:
NEXT_CONFIG_BUILD_OUTPUT=standalone SKIP_ENV_VALIDATION=1 NEXT_PUBLIC_STATIC_WEBSITE_ONLY=true $(PNPM) build
@if [ -d .next/static ]; then mkdir -p .next/standalone/.next && cp -R .next/static .next/standalone/.next/static; fi
+ @if [ -d public ]; then mkdir -p .next/standalone/public && cp -R public/. .next/standalone/public/; fi
diff --git a/frontend/src/app/github-stars/route.ts b/frontend/src/app/github-stars/route.ts
new file mode 100644
index 000000000..d6815912d
--- /dev/null
+++ b/frontend/src/app/github-stars/route.ts
@@ -0,0 +1,42 @@
+import { env } from "@/env";
+
+// Resolve deployment credentials on requests, never while prerendering a build.
+// The explicit fetch revalidate below still caches GitHub data for one hour.
+export const revalidate = 0;
+
+/**
+ * Return only the public star count using the server's runtime GitHub token.
+ * No input; missing credentials, upstream failures, or invalid counts yield 204
+ * so the header hides the counter. Stays outside nginx's /api Gateway proxy.
+ */
+export async function GET() {
+ const token = env.GITHUB_OAUTH_TOKEN;
+ const headers = { "Cache-Control": "no-store" };
+ if (!token) return new Response(null, { status: 204, headers });
+
+ try {
+ const response = await fetch(
+ "https://api.github.com/repos/bytedance/deer-flow",
+ {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ },
+ next: { revalidate: 3600 },
+ },
+ );
+ if (response.ok) {
+ const data = (await response.json()) as { stargazers_count?: unknown };
+ if (
+ typeof data.stargazers_count === "number" &&
+ Number.isSafeInteger(data.stargazers_count) &&
+ data.stargazers_count >= 0
+ ) {
+ return Response.json({ stars: data.stargazers_count }, { headers });
+ }
+ }
+ } catch {
+ // The counter is optional; do not return upstream errors or credentials.
+ }
+ return new Response(null, { status: 204, headers });
+}
diff --git a/frontend/src/components/landing/header.tsx b/frontend/src/components/landing/header.tsx
index 992be510d..5de6076ff 100644
--- a/frontend/src/components/landing/header.tsx
+++ b/frontend/src/components/landing/header.tsx
@@ -1,14 +1,14 @@
-import { StarFilledIcon, GitHubLogoIcon } from "@radix-ui/react-icons";
+import { GitHubLogoIcon } from "@radix-ui/react-icons";
import Link from "next/link";
import { Button } from "@/components/ui/button";
-import { NumberTicker } from "@/components/ui/number-ticker";
import { DEFAULT_LOCALE, type Locale } from "@/core/i18n/locale";
import { getI18n } from "@/core/i18n/server";
import { env } from "@/env";
import { cn } from "@/lib/utils";
import { MobileNav } from "./mobile-nav";
+import { StarCounter } from "./star-counter";
export type HeaderProps = {
className?: string;
@@ -72,8 +72,7 @@ export async function Header({ className, homeURL, locale }: HeaderProps) {
>
Star on GitHub
- {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
- env.GITHUB_OAUTH_TOKEN && }
+ {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && }
@@ -87,39 +86,3 @@ export async function Header({ className, homeURL, locale }: HeaderProps) {
);
}
-
-async function StarCounter() {
- let stars = 10000; // Default value
-
- try {
- const response = await fetch(
- "https://api.github.com/repos/bytedance/deer-flow",
- {
- headers: env.GITHUB_OAUTH_TOKEN
- ? {
- Authorization: `Bearer ${env.GITHUB_OAUTH_TOKEN}`,
- "Content-Type": "application/json",
- }
- : {},
- next: {
- revalidate: 3600,
- },
- },
- );
-
- if (response.ok) {
- const data = await response.json();
- stars = data.stargazers_count ?? stars; // Update stars if API response is valid
- }
- } catch (error) {
- console.error("Error fetching GitHub stars:", error);
- }
- return (
- <>
-
- {stars && (
-
- )}
- >
- );
-}
diff --git a/frontend/src/components/landing/star-counter.tsx b/frontend/src/components/landing/star-counter.tsx
new file mode 100644
index 000000000..dede352e9
--- /dev/null
+++ b/frontend/src/components/landing/star-counter.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import { StarFilledIcon } from "@radix-ui/react-icons";
+import { useEffect, useState } from "react";
+
+import { NumberTicker } from "@/components/ui/number-ticker";
+
+/**
+ * Add the runtime star count to a prerendered header without a client-side token.
+ * No props; hides the optional count while unavailable and cancels on unmount.
+ */
+export function StarCounter() {
+ const [stars, setStars] = useState(null);
+
+ useEffect(() => {
+ const controller = new AbortController();
+ // The root layout has no query provider; this one-shot decorative request
+ // leaves homepage rendering static and GitHub caching on the server.
+ void fetch("/github-stars", { signal: controller.signal })
+ .then(async (response) => {
+ if (!response.ok || response.status === 204) return;
+ const data = (await response.json()) as { stars?: unknown };
+ if (
+ !controller.signal.aborted &&
+ typeof data.stars === "number" &&
+ Number.isSafeInteger(data.stars) &&
+ data.stars >= 0
+ ) {
+ setStars(data.stars);
+ }
+ })
+ .catch(() => {
+ // An unavailable optional counter must not break the GitHub link.
+ });
+ return () => controller.abort();
+ }, []);
+
+ if (stars === null) return null;
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/frontend/src/core/api/fetcher.ts b/frontend/src/core/api/fetcher.ts
index dcb4e509a..ba7eb7f74 100644
--- a/frontend/src/core/api/fetcher.ts
+++ b/frontend/src/core/api/fetcher.ts
@@ -1,6 +1,8 @@
import { buildLoginUrl } from "@/core/auth/types";
+import { isStaticWebsiteOnly } from "@/core/static-mode";
import { UnauthorizedError } from "./errors";
+import { staticApiResponse } from "./static-response";
/** HTTP methods that the gateway's CSRFMiddleware checks. */
export type StateChangingMethod = "POST" | "PUT" | "DELETE" | "PATCH";
@@ -61,6 +63,17 @@ export async function fetch(
): Promise {
const url = typeof input === "string" ? input : input.url;
+ // Static demos have no Gateway. Resolve REST calls before credentials,
+ // CSRF, or redirects; demo assets and explicit mock routes still use HTTP.
+ if (isStaticWebsiteOnly()) {
+ const response = await staticApiResponse(url, {
+ ...init,
+ method:
+ init?.method ?? (typeof input === "string" ? "GET" : input.method),
+ });
+ if (response) return response;
+ }
+
// Inject CSRF for state-changing methods. GET/HEAD/OPTIONS/TRACE skip
// it to mirror the gateway's ``should_check_csrf`` logic exactly.
let headers = init?.headers;
diff --git a/frontend/src/core/api/static-response.ts b/frontend/src/core/api/static-response.ts
new file mode 100644
index 000000000..3680e046f
--- /dev/null
+++ b/frontend/src/core/api/static-response.ts
@@ -0,0 +1,114 @@
+import { getBackendBaseURL } from "@/core/config";
+import type { FeaturesResponse } from "@/core/features/api";
+import type { UserMemory } from "@/core/memory/types";
+
+/**
+ * Resolve Gateway REST calls for the read-only demo without contacting a backend.
+ * Returns null for assets, mock routes, and unrelated origins so normal fetching
+ * can continue. Settings reuse existing same-origin fixtures; unknown API routes
+ * and writes fail locally rather than silently leaking out to a configured Gateway.
+ */
+export async function staticApiResponse(
+ input: string,
+ init?: RequestInit,
+): Promise {
+ const origin =
+ typeof window === "undefined"
+ ? `http://${process.env.HOSTNAME ?? "127.0.0.1"}:${process.env.PORT ?? "3000"}`
+ : window.location.origin;
+ const url = new URL(input, origin);
+ const roots = [
+ new URL(`${getBackendBaseURL()}/api/`, origin),
+ new URL("/api/", origin),
+ ];
+ const root = roots.find(
+ (candidate) =>
+ url.origin === candidate.origin &&
+ url.pathname.startsWith(candidate.pathname),
+ );
+ if (!root) return null;
+
+ init?.signal?.throwIfAborted();
+ const method = (init?.method ?? "GET").toUpperCase();
+ if (method !== "GET" && method !== "HEAD") {
+ return Response.json(
+ { detail: "Unavailable in static demo mode" },
+ { status: 405 },
+ );
+ }
+
+ const path = url.pathname.slice(root.pathname.length).replace(/\/$/, "");
+ // These routes already own the demo settings data; do not maintain a second copy.
+ if (["skills", "mcp/config", "integrations/lark/status"].includes(path)) {
+ return globalThis.fetch(new URL(`/mock/api/${path}`, origin).href, init);
+ }
+
+ let data: unknown;
+ switch (path) {
+ case "features":
+ data = {
+ agents_api: { enabled: false },
+ browser_control: { enabled: false },
+ mcp_tasks: { enabled: false },
+ subagent_batches: {
+ enabled: false,
+ repository_available: false,
+ worker_running: false,
+ max_running: 0,
+ },
+ } satisfies FeaturesResponse;
+ break;
+ case "channels/providers":
+ data = { enabled: false, providers: [] };
+ break;
+ case "channels/connections":
+ data = { connections: [] };
+ break;
+ case "agents":
+ data = { agents: [] };
+ break;
+ case "subagents":
+ data = { subagents: [] };
+ break;
+ case "suggestions/config":
+ data = { enabled: false, max_suggestions: 0 };
+ break;
+ case "memory":
+ case "memory/export": {
+ const empty = { summary: "", updatedAt: "" };
+ data = {
+ version: "1.0",
+ lastUpdated: "",
+ user: { workContext: empty, personalContext: empty, topOfMind: empty },
+ history: {
+ recentMonths: empty,
+ earlierContext: empty,
+ longTermBackground: empty,
+ },
+ facts: [],
+ } satisfies UserMemory;
+ break;
+ }
+ default:
+ // The LangGraph static client already supplies the demo transcript.
+ // There is no additional durable history or live token usage to fetch.
+ if (/^threads\/[^/]+\/messages\/page$/.test(path)) {
+ data = { data: [], has_more: false, next_before_seq: null };
+ } else if (/^threads\/[^/]+\/token-usage$/.test(path)) {
+ data = null;
+ } else if (
+ path === "scheduled-tasks" ||
+ /^scheduled-tasks\/[^/]+\/runs$/.test(path) ||
+ /^threads\/[^/]+\/scheduled-tasks$/.test(path) ||
+ /^threads\/[^/]+\/runs\/[^/]+\/events$/.test(path)
+ ) {
+ data = [];
+ } else {
+ return Response.json(
+ { detail: "Unavailable in static demo mode" },
+ { status: 404 },
+ );
+ }
+ }
+ return method === "HEAD" ? new Response(null) : Response.json(data);
+}
diff --git a/frontend/tests/unit/app/github-stars.test.ts b/frontend/tests/unit/app/github-stars.test.ts
new file mode 100644
index 000000000..bb91494d6
--- /dev/null
+++ b/frontend/tests/unit/app/github-stars.test.ts
@@ -0,0 +1,73 @@
+import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core";
+
+import { GET, revalidate } from "@/app/github-stars/route";
+
+const { env } = rs.hoisted(() => ({ env: { GITHUB_OAUTH_TOKEN: "" } }));
+rs.mock("@/env", () => ({ env }));
+
+beforeEach(() => {
+ env.GITHUB_OAUTH_TOKEN = "";
+});
+afterEach(() => {
+ rs.restoreAllMocks();
+});
+
+describe("runtime GitHub stars", () => {
+ it("does not prerender or fetch when no runtime token is configured", async () => {
+ const fetch = rs.spyOn(globalThis, "fetch");
+ const response = await GET();
+ expect(revalidate).toBe(0);
+ expect(response.status).toBe(204);
+ expect(response.headers.get("Cache-Control")).toBe("no-store");
+ expect(fetch).not.toHaveBeenCalled();
+ });
+
+ it("reads the server token at request time and exposes only the count", async () => {
+ env.GITHUB_OAUTH_TOKEN = "runtime-test-token";
+ const fetch = rs.spyOn(globalThis, "fetch").mockResolvedValue(
+ Response.json({
+ stargazers_count: 43210,
+ private_data: "not-for-the-client",
+ }),
+ );
+ const response = await GET();
+ expect(fetch).toHaveBeenCalledWith(
+ "https://api.github.com/repos/bytedance/deer-flow",
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ Authorization: "Bearer runtime-test-token",
+ }),
+ next: { revalidate: 3600 },
+ }),
+ );
+ expect(await response.json()).toEqual({ stars: 43210 });
+ expect(response.headers.get("Cache-Control")).toBe("no-store");
+ });
+
+ it("hides the count when GitHub rejects the token", async () => {
+ env.GITHUB_OAUTH_TOKEN = "invalid-test-token";
+ rs.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(null, { status: 401 }),
+ );
+ expect((await GET()).status).toBe(204);
+ });
+
+ it("hides the count on network failure without returning error details", async () => {
+ env.GITHUB_OAUTH_TOKEN = "runtime-test-token";
+ rs.spyOn(globalThis, "fetch").mockRejectedValue(
+ new Error("private upstream details"),
+ );
+ const response = await GET();
+ expect(response.status).toBe(204);
+ expect(await response.text()).toBe("");
+ });
+
+ it.each([{}, { stargazers_count: -1 }, { stargazers_count: "81900" }])(
+ "hides invalid GitHub data: %j",
+ async (body) => {
+ env.GITHUB_OAUTH_TOKEN = "runtime-test-token";
+ rs.spyOn(globalThis, "fetch").mockResolvedValue(Response.json(body));
+ expect((await GET()).status).toBe(204);
+ },
+ );
+});
diff --git a/frontend/tests/unit/components/landing/star-counter.dom.test.tsx b/frontend/tests/unit/components/landing/star-counter.dom.test.tsx
new file mode 100644
index 000000000..f5197a1d1
--- /dev/null
+++ b/frontend/tests/unit/components/landing/star-counter.dom.test.tsx
@@ -0,0 +1,61 @@
+import { afterEach, describe, expect, it, rs } from "@rstest/core";
+import { act, cleanup, render, screen } from "@testing-library/react";
+
+import { StarCounter } from "@/components/landing/star-counter";
+
+rs.mock("@/components/ui/number-ticker", () => ({
+ NumberTicker: ({ value }: { value: number }) => {value},
+}));
+
+afterEach(() => {
+ cleanup();
+ rs.restoreAllMocks();
+});
+
+describe("landing star counter", () => {
+ it("loads the runtime count without sending a GitHub token from the browser", async () => {
+ const fetch = rs
+ .spyOn(globalThis, "fetch")
+ .mockResolvedValue(Response.json({ stars: 43210 }));
+ render();
+ await screen.findByText("43210");
+ expect(fetch).toHaveBeenCalledWith("/github-stars", {
+ signal: expect.any(AbortSignal),
+ });
+ });
+
+ it("keeps the count hidden when the runtime reports no available count", async () => {
+ rs.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(null, { status: 204 }),
+ );
+ const { container } = render();
+ await act(async () => {
+ await Promise.resolve();
+ });
+ expect(container.textContent).toBe("");
+ });
+
+ it("keeps the count hidden when the frontend endpoint is unreachable", async () => {
+ rs.spyOn(globalThis, "fetch").mockRejectedValue(
+ new Error("Network unavailable"),
+ );
+ const { container } = render();
+ await act(async () => {
+ await Promise.resolve();
+ });
+ expect(container.textContent).toBe("");
+ });
+
+ it("cancels the request when the header unmounts", () => {
+ const fetch = rs.spyOn(globalThis, "fetch").mockImplementation(
+ () =>
+ new Promise(() => {
+ // Keep the request pending until the component cancels it.
+ }),
+ );
+ const { unmount } = render();
+ const signal = fetch.mock.calls[0]?.[1]?.signal;
+ unmount();
+ expect(signal?.aborted).toBe(true);
+ });
+});
diff --git a/frontend/tests/unit/core/api/static-mode.test.ts b/frontend/tests/unit/core/api/static-mode.test.ts
new file mode 100644
index 000000000..8fec6aaa8
--- /dev/null
+++ b/frontend/tests/unit/core/api/static-mode.test.ts
@@ -0,0 +1,199 @@
+import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core";
+
+import { listAgents } from "@/core/agents/api";
+import { fetch as apiFetch } from "@/core/api/fetcher";
+import {
+ listChannelConnections,
+ listChannelProviders,
+} from "@/core/channels/api";
+import { fetchFeatures } from "@/core/features/api";
+import { loadLarkIntegrationStatus } from "@/core/integrations/lark/api";
+import { loadMCPConfig } from "@/core/mcp/api";
+import { loadMemory } from "@/core/memory/api";
+import {
+ createScheduledTask,
+ fetchScheduledTaskRuns,
+ fetchScheduledTasks,
+ fetchThreadScheduledTasks,
+} from "@/core/scheduled-tasks/api";
+import { loadSkills } from "@/core/skills/api";
+import { listSubagents } from "@/core/subagents/api";
+import { loadSuggestionsConfig } from "@/core/suggestions/api";
+import { fetchSubtaskSteps } from "@/core/tasks/api";
+import { fetchThreadTokenUsage } from "@/core/threads/api";
+
+const { env } = rs.hoisted(() => ({
+ env: {
+ NEXT_PUBLIC_STATIC_WEBSITE_ONLY: "true",
+ NEXT_PUBLIC_BACKEND_BASE_URL: "",
+ },
+}));
+rs.mock("@/env", () => ({ env }));
+
+const network = rs.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
+ Response.json({}),
+);
+
+beforeEach(() => {
+ rs.stubEnv("HOSTNAME", undefined);
+ rs.stubEnv("PORT", undefined);
+ env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY = "true";
+ env.NEXT_PUBLIC_BACKEND_BASE_URL = "";
+ network.mockReset();
+ network.mockResolvedValue(Response.json({}));
+ rs.stubGlobal("fetch", network);
+});
+
+afterEach(() => {
+ rs.unstubAllEnvs();
+ rs.unstubAllGlobals();
+});
+
+describe("static website API requests", () => {
+ it("loads optional capabilities and empty catalogs without the Gateway", async () => {
+ await expect(fetchFeatures()).resolves.toMatchObject({
+ agents_api: { enabled: false },
+ browser_control: { enabled: false },
+ mcp_tasks: { enabled: false },
+ subagent_batches: { repository_available: false, worker_running: false },
+ });
+ await expect(listChannelProviders()).resolves.toEqual({
+ enabled: false,
+ providers: [],
+ });
+ await expect(listChannelConnections()).resolves.toEqual([]);
+ await expect(listAgents()).resolves.toEqual([]);
+ await expect(listSubagents()).resolves.toEqual([]);
+ await expect(loadSuggestionsConfig()).resolves.toMatchObject({
+ enabled: false,
+ });
+ await expect(fetchScheduledTasks()).resolves.toEqual([]);
+ await expect(fetchThreadScheduledTasks("demo / thread")).resolves.toEqual(
+ [],
+ );
+ await expect(fetchScheduledTaskRuns("task / id")).resolves.toEqual([]);
+ await expect(fetchSubtaskSteps("thread", "run", "task")).resolves.toEqual(
+ [],
+ );
+ await expect(fetchThreadTokenUsage("demo")).resolves.toBeNull();
+ const history = await apiFetch("/api/threads/demo/messages/page?limit=50");
+ expect(await history.json()).toEqual({
+ data: [],
+ has_more: false,
+ next_before_seq: null,
+ });
+ await expect(loadMemory()).resolves.toMatchObject({
+ facts: [],
+ user: {},
+ history: {},
+ });
+ expect(network).not.toHaveBeenCalled();
+ });
+
+ it("uses the existing same-origin settings fixtures even with a configured Gateway", async () => {
+ env.NEXT_PUBLIC_BACKEND_BASE_URL = "https://gateway.example/prefix";
+ network.mockResolvedValueOnce(Response.json({ skills: [] }));
+ await expect(loadSkills()).resolves.toEqual([]);
+ network.mockResolvedValueOnce(Response.json({ mcp_servers: {} }));
+ await expect(loadMCPConfig()).resolves.toEqual({ mcp_servers: {} });
+ network.mockResolvedValueOnce(Response.json({ installed: false }));
+ await expect(loadLarkIntegrationStatus()).resolves.toMatchObject({
+ installed: false,
+ });
+ expect(network.mock.calls.map(([url]) => url)).toEqual([
+ "http://127.0.0.1:3000/mock/api/skills",
+ "http://127.0.0.1:3000/mock/api/mcp/config",
+ "http://127.0.0.1:3000/mock/api/integrations/lark/status",
+ ]);
+ await expect(fetchFeatures()).resolves.toMatchObject({
+ agents_api: { enabled: false },
+ });
+ expect(network).toHaveBeenCalledTimes(3);
+ });
+
+ it("rejects writes and unsupported endpoints locally instead of reporting fake success", async () => {
+ await expect(
+ createScheduledTask({
+ context_mode: "fresh_thread_per_run",
+ title: "Demo",
+ prompt: "Demo",
+ schedule_type: "once",
+ schedule_spec: {},
+ timezone: "UTC",
+ }),
+ ).rejects.toThrow("Unavailable in static demo mode");
+ expect((await apiFetch("/api/new-feature")).status).toBe(404);
+ expect(
+ (
+ await apiFetch(
+ new Request("http://127.0.0.1:3000/api/subagents", {
+ method: "DELETE",
+ }),
+ )
+ ).status,
+ ).toBe(405);
+ expect(network).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ ["demo.internal", undefined, "http://demo.internal:3000"],
+ [undefined, "4000", "http://127.0.0.1:4000"],
+ ["demo.internal", "4000", "http://demo.internal:4000"],
+ ])(
+ "uses the server origin with HOSTNAME=%s and PORT=%s",
+ async (hostname, port, origin) => {
+ rs.stubEnv("HOSTNAME", hostname);
+ rs.stubEnv("PORT", port);
+ await apiFetch("/api/skills");
+ expect(network).toHaveBeenCalledWith(
+ `${origin}/mock/api/skills`,
+ expect.anything(),
+ );
+ network.mockClear();
+ const response = await apiFetch(
+ new Request(`${origin}/api/subagents`, { method: "DELETE" }),
+ );
+ expect(response.status).toBe(405);
+ expect(network).not.toHaveBeenCalled();
+ },
+ );
+
+ it("leaves demo assets and unrelated external requests intact", async () => {
+ await apiFetch("/demo/threads/demo/thread.json");
+ await apiFetch("https://external.example/api/features");
+ expect(network).toHaveBeenCalledTimes(2);
+ });
+
+ it("uses the current browser origin for fixtures and respects cancellation", async () => {
+ rs.stubEnv("HOSTNAME", "demo.internal");
+ rs.stubEnv("PORT", "4000");
+ rs.stubGlobal("window", { location: { origin: "http://127.0.0.1:3000" } });
+ await apiFetch("/api/skills");
+ expect(network).toHaveBeenCalledWith(
+ "http://127.0.0.1:3000/mock/api/skills",
+ expect.anything(),
+ );
+ network.mockClear();
+ const controller = new AbortController();
+ controller.abort();
+ await expect(
+ apiFetch("/api/features", { signal: controller.signal }),
+ ).rejects.toThrow();
+ expect(
+ await (await apiFetch("/api/features", { method: "HEAD" })).text(),
+ ).toBe("");
+ expect(network).not.toHaveBeenCalled();
+ });
+
+ it("preserves normal backend requests unless the flag is exactly true", async () => {
+ env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY = "false";
+ network.mockResolvedValue(Response.json({ agents_api: { enabled: true } }));
+ await expect(fetchFeatures()).resolves.toMatchObject({
+ agents_api: { enabled: true },
+ });
+ expect(network).toHaveBeenCalledWith(
+ "/api/features",
+ expect.objectContaining({ credentials: "include" }),
+ );
+ });
+});