feat: serve a root page instead of 404
sheets.bds.oncarlos.com/ answered "404 page not found" because the app genuinely has no "/" route — it is a headless engine. A 404 is honest about the route and misleading about the deploy: it looks like a broken instance rather than a working one with no UI. The page says what this address is, that it has no UI deliberately, and which two routes do answer. Those two are registered by rastrillo.Serve, not by this package, so both were verified against the deployed instance before being named here (/healthz -> ok, /api/version -> 0ccd7ab). Unstyled. estilo exists and is pinned to day, but this app does not consume it and nobody decided that it should. The "/" route is exact, not a catch-all: a catch-all would serve this page at every URL, so a mistyped path would look like a working page instead of a 404. TestIndexIsNotACatchAll pins that, and all three new tests were watched failing against a deliberately broken page before being kept.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APtNStXDZ5H2fAc1ZuGqtS
3 files changed,
+113
−0
internal/sheets/app.go+4 −0internal/sheets/page.go+48 −0internal/sheets/page_test.go+61 −0
Intent
diff --git a/internal/sheets/app.go b/internal/sheets/app.go| index 115d8bc..4520f71 100644 |
| --- a/internal/sheets/app.go |
| +++ b/internal/sheets/app.go |
| @@ -30,5 +30,9 @@ func (a *App) Router() http.Handler { |
| r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) { |
| w.WriteHeader(http.StatusOK) |
| }) |
| + // Exact "/" only, not a catch-all: chi answers 404 for every |
| + // other path, which is what an operator hitting a wrong URL |
| + // should see (TestIndexIsNotACatchAll pins this). |
| + r.Get("/", a.handleIndex) |
| return r |
| } |
diff --git a/internal/sheets/page.go b/internal/sheets/page.go| new file mode 100644 |
| index 0000000..bdc0aae |
| --- /dev/null |
| +++ b/internal/sheets/page.go |
| @@ -0,0 +1,48 @@ |
| +package sheets |
| + |
| +import "net/http" |
| + |
| +// indexPage is the entire root page: bare fact, not marketing. It has |
| +// no dynamic content and no user input to escape, so a plain constant |
| +// string stands in for html/template here rather than pretending to |
| +// need one. |
| +// |
| +// No stylesheet is linked. oficina/estilo exists and is pinned to |
| +// rastrillo's day theme, but this app does not consume it and nobody |
| +// has decided that it should — the grid slice is where that decision |
| +// belongs. Semantic, unstyled HTML is the honest choice until then. |
| +// |
| +// The two routes named below are registered by rastrillo.Serve's outer |
| +// mux, not by this package (see cmd/sheets/main.go). They are named |
| +// here because someone who lands on / is usually an operator checking |
| +// whether the instance is alive, and both were verified answering on |
| +// the deployed instance. |
| +const indexPage = `<!doctype html> |
| +<html lang="en"> |
| +<head> |
| +<meta charset="utf-8"> |
| +<title>Oficina Sheets</title> |
| +</head> |
| +<body> |
| +<main> |
| +<h1>Oficina Sheets</h1> |
| +<p>This is the Oficina Sheets engine: sealed storage, formula |
| +evaluation, and XLSX import and export.</p> |
| +<p>It has no user interface yet. That is deliberate: the grid is the |
| +next slice of work, not built here.</p> |
| +<p>In production this instance answers <code>GET /healthz</code> and |
| +<code>GET /api/version</code>. There is nothing else to reach at this |
| +address.</p> |
| +<p>The design is at |
| +<code>docs/superpowers/specs/2026-08-30-sheets-design.md</code> in this |
| +repository.</p> |
| +</main> |
| +</body> |
| +</html> |
| +` |
| + |
| +// handleIndex serves the one page this app has. |
| +func (a *App) handleIndex(w http.ResponseWriter, _ *http.Request) { |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + w.Write([]byte(indexPage)) |
| +} |
diff --git a/internal/sheets/page_test.go b/internal/sheets/page_test.go| new file mode 100644 |
| index 0000000..e1c9ea6 |
| --- /dev/null |
| +++ b/internal/sheets/page_test.go |
| @@ -0,0 +1,61 @@ |
| +package sheets |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func get(t *testing.T, path string) *httptest.ResponseRecorder { |
| + t.Helper() |
| + rec := httptest.NewRecorder() |
| + New(nil).Router().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) |
| + return rec |
| +} |
| + |
| +func TestIndexServesHTML(t *testing.T) { |
| + rec := get(t, "/") |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("GET / = %d, want 200", rec.Code) |
| + } |
| + if ct := rec.Header().Get("Content-Type"); ct != "text/html; charset=utf-8" { |
| + t.Errorf("Content-Type = %q", ct) |
| + } |
| + // The page's job is to say what this address is and what it is not. |
| + for _, want := range []string{ |
| + "<h1>Oficina Sheets</h1>", |
| + "no user interface yet", |
| + "/healthz", |
| + "/api/version", |
| + "2026-08-30-sheets-design.md", |
| + } { |
| + if !strings.Contains(rec.Body.String(), want) { |
| + t.Errorf("body missing %q", want) |
| + } |
| + } |
| +} |
| + |
| +// TestIndexIsNotACatchAll is the point of the "/" route being exact. |
| +// A catch-all would serve the page at every URL, so a mistyped path |
| +// would look like a working page instead of a 404 — and nothing else |
| +// in the suite would notice. |
| +func TestIndexIsNotACatchAll(t *testing.T) { |
| + for _, path := range []string{"/nope", "/api/sheets", "/healthz/x"} { |
| + if code := get(t, path).Code; code != http.StatusNotFound { |
| + t.Errorf("GET %s = %d, want 404", path, code) |
| + } |
| + } |
| +} |
| + |
| +// TestIndexLeaksNothing: the one rule reaches even a static page. No |
| +// cell content, sheet title or tab name exists here, and no build, |
| +// path or host detail should either. |
| +func TestIndexLeaksNothing(t *testing.T) { |
| + body := get(t, "/").Body.String() |
| + for _, forbidden := range []string{"/home/", "oncarlos.com", "sqlite", ".db"} { |
| + if strings.Contains(strings.ToLower(body), strings.ToLower(forbidden)) { |
| + t.Errorf("page contains %q", forbidden) |
| + } |
| + } |
| +} |