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)
}