From 7cc5e80671318cad3722a4dbcf16f8fa725ae89c Mon Sep 17 00:00:00 2001 From: KB Date: Tue, 21 Jul 2026 22:20:12 +0530 Subject: [PATCH 1/5] fix(server): return JSON 404 for unmatched API-shaped requests (#180/#197/#189) The catch-all only refused SPA-fallback for /api/*, but the JSON API is also mounted under /settings/*, /config, /session, etc. An unmatched request to one of those paths returned 200 + index.html, which the SPA JSON.parses and crashes on. Add wantsJson() to detect API/data calls (JSON content-type, or an Accept header wanting JSON without text/html) and return a JSON 404 for them before falling back to the SPA shell. Browser navigations are unaffected. --- backend/cli/src/server/server.ts | 7 ++- backend/cli/src/web/serve.ts | 12 ++++ backend/cli/test/server/spa-fallback.test.ts | 59 ++++++++++++++++++++ backend/cli/test/web/serve.test.ts | 28 ++++++++++ 4 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 backend/cli/test/server/spa-fallback.test.ts create mode 100644 backend/cli/test/web/serve.test.ts diff --git a/backend/cli/src/server/server.ts b/backend/cli/src/server/server.ts index 8f697005..7b8e317c 100644 --- a/backend/cli/src/server/server.ts +++ b/backend/cli/src/server/server.ts @@ -5,7 +5,7 @@ import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler import { Hono } from "hono" import { cors } from "hono/cors" import { streamSSE } from "hono/streaming" -import { serveWebAsset } from "../web/serve" +import { serveWebAsset, wantsJson } from "../web/serve" import { isAllowedHost, isAllowedOrigin, isCrossOrigin } from "./host-guard" import { timingSafeEqual } from "../util/timing-safe" import { FolderResolveRoutes } from "./routes/folder-resolve" @@ -640,6 +640,11 @@ export namespace Server { // try to JSON.parse `` and crash (#180/#197/#189). + */ +export function wantsJson(accept: string | null, contentType: string | null): boolean { + if (contentType?.includes("application/json")) return true + const a = accept ?? "" + return a.includes("application/json") && !a.includes("text/html") +} + export async function serveWebAsset(c: Context): Promise { if (!WEB_INDEX) return undefined diff --git a/backend/cli/test/server/spa-fallback.test.ts b/backend/cli/test/server/spa-fallback.test.ts new file mode 100644 index 00000000..944214f6 --- /dev/null +++ b/backend/cli/test/server/spa-fallback.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { Instance } from "../../src/project/instance" +import { Server } from "../../src/server/server" +import { Log } from "../../src/util/log" + +const projectRoot = path.join(__dirname, "../..") +Log.init({ print: false }) + +describe("spa fallback", () => { + test("unmatched API-shaped request under /settings gets a JSON 404, not SPA HTML", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const fetch = Server.internalFetch() + const response = await fetch("http://openscience.internal/settings/nonexistent", { + headers: { "content-type": "application/json" }, + }) + + expect(response.status).toBe(404) + expect(response.headers.get("content-type")).toContain("application/json") + + const text = await response.text() + expect(text.startsWith("<")).toBe(false) + expect(JSON.parse(text)).toEqual({ error: "not_found", path: "/settings/nonexistent" }) + }, + }) + }) + + test("browser navigation to an unmatched route still gets the SPA index.html", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const fetch = Server.internalFetch() + const response = await fetch("http://openscience.internal/definitely/not/a/route", { + headers: { accept: "text/html" }, + }) + + expect(response.status).toBe(200) + expect(response.headers.get("content-type")).toContain("text/html") + + const text = await response.text() + expect(text.toLowerCase().startsWith(" { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const fetch = Server.internalFetch() + const response = await fetch("http://openscience.internal/api/also-nonexistent") + + expect(response.status).toBe(404) + }, + }) + }) +}) diff --git a/backend/cli/test/web/serve.test.ts b/backend/cli/test/web/serve.test.ts new file mode 100644 index 00000000..fdc7f974 --- /dev/null +++ b/backend/cli/test/web/serve.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test" +import { wantsJson } from "../../src/web/serve" + +describe("wantsJson", () => { + test("content-type application/json → true", () => { + expect(wantsJson(null, "application/json")).toBe(true) + }) + + test("accept includes application/json, no text/html → true", () => { + expect(wantsJson("application/json, text/plain", null)).toBe(true) + }) + + test("browser navigation accept header → false", () => { + expect(wantsJson("text/html,application/xhtml+xml,*/*", null)).toBe(false) + }) + + test("text/html present alongside application/json → treated as navigation, false", () => { + expect(wantsJson("text/html,application/json", null)).toBe(false) + }) + + test("wildcard accept → false", () => { + expect(wantsJson("*/*", null)).toBe(false) + }) + + test("both null → false", () => { + expect(wantsJson(null, null)).toBe(false) + }) +}) From bb584f17f7aceb3ccf0bc00def844d17a7cf006e Mon Sep 17 00:00:00 2001 From: KB Date: Tue, 21 Jul 2026 22:29:13 +0530 Subject: [PATCH 2/5] fix(workspace): guard settingsApi against non-JSON 2xx responses (#180) If a settings route ever returns a 200 with an HTML body (e.g. the SPA fallback for a missed API route), res.json() throws an opaque "SyntaxError: Unexpected token '<'". Check the content-type before parsing and throw a descriptive error naming the path, status, and received content-type instead. --- .../src/components/settings/api.test.ts | 40 +++++++++++++++++++ .../workspace/src/components/settings/api.ts | 4 ++ 2 files changed, 44 insertions(+) create mode 100644 frontend/workspace/src/components/settings/api.test.ts diff --git a/frontend/workspace/src/components/settings/api.test.ts b/frontend/workspace/src/components/settings/api.test.ts new file mode 100644 index 00000000..4be2e714 --- /dev/null +++ b/frontend/workspace/src/components/settings/api.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test" +import { settingsApi } from "./api" + +const base = "http://x" +const path = "/settings/local" + +describe("settingsApi", () => { + test("throws a descriptive error when a 200 response is not JSON", async () => { + const fetchFn = (async () => + new Response("", { + status: 200, + headers: { "content-type": "text/html; charset=utf-8" }, + })) as unknown as typeof fetch + + const error = await settingsApi(base, fetchFn, path).catch((e: Error) => e) + + expect(error).toBeInstanceOf(Error) + expect(error.message).toContain("/settings/local") + expect(error.message).toContain("Expected JSON") + expect(error.message).not.toContain("Unexpected token") + }) + + test("resolves with parsed JSON on a normal 200 response", async () => { + const fetchFn = (async () => Response.json({ ok: true }, { status: 200 })) as unknown as typeof fetch + + expect(await settingsApi<{ ok: boolean }>(base, fetchFn, path)).toEqual({ ok: true }) + }) + + test("resolves with undefined on 204", async () => { + const fetchFn = (async () => new Response(null, { status: 204 })) as unknown as typeof fetch + + expect(await settingsApi(base, fetchFn, path)).toBeUndefined() + }) + + test("preserves the existing non-ok error path", async () => { + const fetchFn = (async () => new Response("boom", { status: 500 })) as unknown as typeof fetch + + await expect(settingsApi(base, fetchFn, path)).rejects.toThrow(/boom|500/) + }) +}) diff --git a/frontend/workspace/src/components/settings/api.ts b/frontend/workspace/src/components/settings/api.ts index 783a44f6..c490e998 100644 --- a/frontend/workspace/src/components/settings/api.ts +++ b/frontend/workspace/src/components/settings/api.ts @@ -17,5 +17,9 @@ export async function settingsApi( throw new Error(text || `${res.status} ${res.statusText}`) } if (res.status === 204) return undefined as T + const contentType = res.headers.get("content-type") ?? "" + if (!contentType.includes("application/json")) { + throw new Error(`Expected JSON from ${path}, but got ${res.status} (${contentType || "no content-type"})`) + } return (await res.json()) as T } From 2fe04450df8490f535055473ead348bc27e9d6af Mon Sep 17 00:00:00 2001 From: KB Date: Tue, 21 Jul 2026 22:37:47 +0530 Subject: [PATCH 3/5] refactor(server): rename wantsJson param + assert CSP in nav test --- backend/cli/src/web/serve.ts | 4 ++-- backend/cli/test/server/spa-fallback.test.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/cli/src/web/serve.ts b/backend/cli/src/web/serve.ts index 8bbdf316..5097b29b 100644 --- a/backend/cli/src/web/serve.ts +++ b/backend/cli/src/web/serve.ts @@ -39,8 +39,8 @@ function contentType(reqPath: string): string { * 404 over the SPA index.html fallback — SPA-falling-back a data call makes the * client JSON.parse `` and crash (#180/#197/#189). */ -export function wantsJson(accept: string | null, contentType: string | null): boolean { - if (contentType?.includes("application/json")) return true +export function wantsJson(accept: string | null, contentTypeHeader: string | null): boolean { + if (contentTypeHeader?.includes("application/json")) return true const a = accept ?? "" return a.includes("application/json") && !a.includes("text/html") } diff --git a/backend/cli/test/server/spa-fallback.test.ts b/backend/cli/test/server/spa-fallback.test.ts index 944214f6..0181fe9e 100644 --- a/backend/cli/test/server/spa-fallback.test.ts +++ b/backend/cli/test/server/spa-fallback.test.ts @@ -38,6 +38,7 @@ describe("spa fallback", () => { expect(response.status).toBe(200) expect(response.headers.get("content-type")).toContain("text/html") + expect(response.headers.get("content-security-policy")).toContain("default-src 'self'") const text = await response.text() expect(text.toLowerCase().startsWith(" Date: Tue, 21 Jul 2026 23:25:43 +0530 Subject: [PATCH 4/5] fix(server): tolerate trailing slash so /settings/local/ resolves (#180/#197/#189) Hono's default strict routing treats /settings/local and /settings/local/ as distinct routes, so the mounted LocalModelsRoutes sub-app only matched the no-slash form. The Local Models panel requests the trailing-slash form, missed the real route, fell through to the SPA catch-all, and crashed on res.json() parsing HTML. Set strict: false on the top-level Hono app so /x/ resolves like /x for every route (propagates through .route() to mounted sub-apps), and send the canonical no-trailing-slash path from the two LocalModels call sites that reported crashes (load and add). --- backend/cli/src/server/server.ts | 2 +- backend/cli/test/server/spa-fallback.test.ts | 18 ++++++++++++++++++ .../src/components/settings/LocalModels.tsx | 4 ++-- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/backend/cli/src/server/server.ts b/backend/cli/src/server/server.ts index 7b8e317c..2976b49c 100644 --- a/backend/cli/src/server/server.ts +++ b/backend/cli/src/server/server.ts @@ -80,7 +80,7 @@ export namespace Server { return _server?.requestIP(req)?.address } - const app = new Hono() + const app = new Hono({ strict: false }) export const App: () => Hono = lazy( () => // TODO: Break server.ts into smaller route files to fix type inference diff --git a/backend/cli/test/server/spa-fallback.test.ts b/backend/cli/test/server/spa-fallback.test.ts index 0181fe9e..c949bd64 100644 --- a/backend/cli/test/server/spa-fallback.test.ts +++ b/backend/cli/test/server/spa-fallback.test.ts @@ -57,4 +57,22 @@ describe("spa fallback", () => { }, }) }) + + test("GET /settings/local/ (trailing slash) resolves to the real route, not the catch-all", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const fetch = Server.internalFetch() + const response = await fetch("http://openscience.internal/settings/local/", { + headers: { "content-type": "application/json" }, + }) + + expect(response.status).toBe(200) + expect(response.headers.get("content-type")).toContain("application/json") + + const body = await response.json() + expect(Array.isArray(body.presets)).toBe(true) + }, + }) + }) }) diff --git a/frontend/workspace/src/components/settings/LocalModels.tsx b/frontend/workspace/src/components/settings/LocalModels.tsx index f827542f..1e3651e9 100644 --- a/frontend/workspace/src/components/settings/LocalModels.tsx +++ b/frontend/workspace/src/components/settings/LocalModels.tsx @@ -54,7 +54,7 @@ const LocalModels: Component = () => { call<{ detected: Detected[] }>("/detect").then((r) => r.detected), ) const [configured, { refetch: refetchConfigured }] = createResource(() => - call<{ providers: Configured[] }>("/").then((r) => r.providers), + call<{ providers: Configured[] }>("").then((r) => r.providers), ) const [status, { refetch: refetchStatus }] = createResource(() => call<{ runtimes: Runtime[] }>("/status").then((r) => r.runtimes), @@ -80,7 +80,7 @@ const LocalModels: Component = () => { const addRuntime = (d: Detected) => guard( () => - call("/", { + call("", { method: "POST", body: JSON.stringify({ url: d.baseURL, id: d.id, name: `${d.name} (local)`, models: d.models }), }), From a4cc725b5553b34071baf4a1e59c2f1aba237b20 Mon Sep 17 00:00:00 2001 From: KB Date: Tue, 21 Jul 2026 23:31:47 +0530 Subject: [PATCH 5/5] fix(workspace): normalize remaining /settings/local call sites to canonical path --- frontend/workspace/src/components/settings/LocalModels.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/workspace/src/components/settings/LocalModels.tsx b/frontend/workspace/src/components/settings/LocalModels.tsx index 1e3651e9..a73d7b17 100644 --- a/frontend/workspace/src/components/settings/LocalModels.tsx +++ b/frontend/workspace/src/components/settings/LocalModels.tsx @@ -106,7 +106,7 @@ const LocalModels: Component = () => { showToast({ title: `${rt.name} isn't installed`, description: `Install it, then start it here.` }) window.open(r.install ?? rt.install, "_blank", "noopener") } else if (r.running && r.models?.length) { - await call("/", { + await call("", { method: "POST", body: JSON.stringify({ url: rt.baseURL, id: rt.id, name: `${rt.name} (local)`, models: r.models }), }) @@ -126,7 +126,7 @@ const LocalModels: Component = () => { const addRunning = (rt: Runtime) => guard( () => - call("/", { + call("", { method: "POST", body: JSON.stringify({ url: rt.baseURL, id: rt.id, name: `${rt.name} (local)`, models: rt.models }), }), @@ -172,7 +172,7 @@ const LocalModels: Component = () => { guard(async () => { const models = [...selected()] if (!models.length) throw new Error("Select at least one model.") - await call("/", { + await call("", { method: "POST", body: JSON.stringify({ url: url().trim(), key: key().trim() || undefined, models }), })