From eef37fa89be8996d5cec0a10ae88ea067c255b7a Mon Sep 17 00:00:00 2001 From: "Snow Lee (Sungwon)" Date: Wed, 19 Aug 2026 12:21:09 -0700 Subject: [PATCH] fix(webapp): the hub 404s a root file it does not have (BEA-148) (#184) GET /llms.txt answered 200 with the sign-in page. So did /robots.txt, /sitemap.xml and every mistyped root file: the SPA fallback treated only api/, auth/ and s/ as genuine 404s, so a crawler probing any conventional path got a success status and a chunk of login HTML. In hub mode a first path segment is a project id (UUID or p-xxxxxxxx) or a reserved word, none of which contain a dot, so a single dotted segment with no embedded asset can only be a file that is not there. The check runs after the asset lookup, so a real root asset (share-mermaid.js today, favicon.ico whenever the build emits one) is unaffected with no allowlist to maintain. Gated on hub mode: the plain-folder viewer shares this handler and there /README.md IS the route for a file, so ungating this would make every top-level file in every `bdrive serve ` unreachable. There is a test that fails if the gate is removed. Co-authored-by: Claude Opus 5 (1M context) --- internal/webapp/sec_config_test.go | 52 ++++++++++++++++++++++++++++++ internal/webapp/server.go | 20 ++++++++++-- 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/internal/webapp/sec_config_test.go b/internal/webapp/sec_config_test.go index 269c04d..8408423 100644 --- a/internal/webapp/sec_config_test.go +++ b/internal/webapp/sec_config_test.go @@ -689,3 +689,55 @@ func seccfgMedian(d []time.Duration) time.Duration { sort.Slice(c, func(i, j int) bool { return c[i] < c[j] }) return c[len(c)/2] } + +// TestFrontendRootDottedPathsAre404: a root-level path shaped like a file +// (/llms.txt, /robots.txt) that matches no embedded asset must 404 rather than +// answer the app shell — a soft 200 of login HTML told every crawler probing a +// conventional path that the file exists. Deeper dots are real client routes. +func TestFrontendRootDottedPathsAre404(t *testing.T) { + h, _, _, p := permHub(t) + + for _, target := range []string{"/llms.txt", "/robots.txt", "/sitemap.xml", "/nope.json"} { + rec := seccfgRaw(t, h, target) + if rec.Code != 404 { + t.Errorf("GET %s: %d, want 404 (got %s) — the SPA fallback still masks a missing root file", + target, rec.Code, rec.Header().Get("Content-Type")) + } + } + + // A root-level dotted path that IS an embedded asset keeps being served: + // the rule is "no embedded asset", not "has a dot", because the check runs + // after the asset lookup. share-mermaid.js is the live case (the /s/ share + // pages import it); a favicon.ico would be the next one, with no allowlist + // to remember to update. + rec := seccfgRaw(t, h, "/share-mermaid.js") + if rec.Code != 200 { + t.Errorf("GET /share-mermaid.js: %d, want 200 — the dotted-path 404 is deciding on the URL "+ + "instead of on whether the asset exists, and share pages just lost their mermaid renderer", rec.Code) + } + + // Everything that is a genuine client route still resolves to the shell. + // /index.html is here because the asset block skips it deliberately and + // falls through: without the explicit exclusion the rule above would 404 it. + for _, target := range []string{"/", "/index.html", "/" + p.ID, "/" + p.ID + "/dashboard", "/" + p.ID + "/notes/index.md"} { + rec := seccfgRaw(t, h, target) + if rec.Code != 200 || !strings.Contains(rec.Body.String(), "
") { + t.Errorf("GET %s: %d — a client route stopped resolving to the app shell", target, rec.Code) + } + } +} + +// TestFrontendVolumeModeServesRootFiles: the dotted-root-path 404 above is +// hub-only reasoning. The plain-folder viewer shares this handler, and there +// /README.md IS the route for a file (router.ts parsePath, non-hub branch), so +// the rule is gated on hub mode. THIS TEST FAILS IF THAT GATE IS REMOVED — +// ungated, the fix 404s every top-level file in every `bdrive serve `. +func TestFrontendVolumeModeServesRootFiles(t *testing.T) { + h := dirServer(t, map[string]string{"README.md": "# Local"}) + + rec := seccfgRaw(t, h, "/README.md") + if rec.Code != 200 || !strings.Contains(rec.Body.String(), "
") { + t.Errorf("GET /README.md in volume mode: %d — the hub-mode gate on the dotted-path 404 is gone; "+ + "every root-level file in a plain-folder viewer is now unreachable", rec.Code) + } +} diff --git a/internal/webapp/server.go b/internal/webapp/server.go index 35bd17d..b318354 100644 --- a/internal/webapp/server.go +++ b/internal/webapp/server.go @@ -906,8 +906,9 @@ func (s *Server) Handler() http.Handler { // frontend serves the embedded single-page app. Real asset files (app.js, // style.css) are served directly; every other GET that isn't an API, auth, -// or share route returns index.html, so client-side routes like -// // and /join/ survive a deep link or refresh. +// or share route — or, on a hub, a root-level path shaped like a file — +// returns index.html, so client-side routes like // and +// /join/ survive a deep link or refresh. func (s *Server) frontend(static fs.FS) http.HandlerFunc { files := http.FileServerFS(static) index, _ := fs.ReadFile(static, "index.html") @@ -970,6 +971,21 @@ func (s *Server) frontend(static fs.FS) http.HandlerFunc { } } } + // No embedded asset matched, so a root-level dotted path is a request + // for a file that does not exist, not a client route: in hub mode the + // first segment is a project id (projectIDRe: UUID or p-xxxxxxxx) or a + // reserved word (orgs/, billing/, join/), none of which contain a dot. + // Answering the shell made /llms.txt, /robots.txt and every mistyped + // root file look like they exist — a soft 200 of login HTML to any + // crawler probing a conventional path. Deeper dots (//notes/a.md) + // are real client routes and untouched. index.html is excluded because + // the asset block above skips it deliberately and it must keep + // answering the shell. + if s.Root != nil && upath != "index.html" && + !strings.Contains(upath, "/") && strings.Contains(upath, ".") { + http.NotFound(w, r) + return + } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write(index) }