mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-26 19:31:43 +00:00
0c908d2bca
Closes #1551. ## Problem `/api/*` Go responses emit no `Cache-Control` header. CDNs (Cloudflare, nginx, Varnish) default to caching `application/json` for **15 min – 4 h** when no directive is set. Observed against a public Cloudflare-fronted CoreScope instance (`meshcore.meshat.se`): - 17 consecutive polls of `/api/observers` over ~10 min returned byte-identical responses - Response headers showed `cf-cache-status: HIT`, `age: 878` (~15 min) - Cache-busting query param → `cf-cache-status: MISS` with fresh `last_seen` values This causes WebSocket pushes to diverge from REST GETs (WS fresh, REST stale) and produces false-positive stale/online flips for observers near the 10-min threshold. ## Fix New `noStoreAPIMiddleware` in `cmd/server/routes.go` wired into the gorilla/mux chain alongside the existing `backfillStatusMiddleware`. Sets `Cache-Control: no-store` on every response whose request path starts with `/api/`. ## Design choice: `no-store` vs `private, max-age=0` Chose `no-store`. CoreScope's REST endpoints are fresh-on-every-request by contract (WS pushes diff against REST GETs), so any intermediary cache is wrong. `no-store` forbids **any** cache (CDN, browser, intermediary). `private, max-age=0` still permits short browser caches and some intermediaries — no benefit here. ## Scope discipline - `/api/` prefix only. - Static assets (`/`, `/app.js`, `/style.css`, …) keep their existing `no-cache, no-store, must-revalidate` headers from `spaHandler` in `main.go`. Hashed assets stay CDN-cacheable by design. - The middleware runs for **all** registered routes including the websocket upgrade HTTP request, since `/ws` is served through the same mux. ## TDD - **Red** `1beb5432`: `cmd/server/cache_control_api_test.go` asserts `Cache-Control: no-store` on `/api/stats`, `/api/observers`, `/api/packets`, `/api/nodes`, and asserts the middleware does NOT leak onto `/` or `/app.js`. Fails on assertion (no Cache-Control header emitted) — not a compile error. - **Green** `13be675f`: middleware + wiring. All assertions pass; full `cmd/server` suite stays green. ## Files - `cmd/server/routes.go` — middleware definition + `r.Use(noStoreAPIMiddleware)` - `cmd/server/cache_control_api_test.go` — 6 sub-tests across 2 top-level tests ## Preflight `bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master` → clean (exit 0). --------- Co-authored-by: corescope-bot <bot@corescope>
99 lines
3.1 KiB
Go
99 lines
3.1 KiB
Go
package main
|
|
|
|
// Issue #1551: /api/* responses must emit Cache-Control: no-store so
|
|
// CDNs (Cloudflare, nginx, Varnish) do not cache JSON. Static assets
|
|
// (app.js, /, etc.) intentionally remain CDN-cacheable.
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/gorilla/mux"
|
|
)
|
|
|
|
// TestAPIRoutesEmitNoStoreCacheControl asserts every covered /api/*
|
|
// endpoint sets Cache-Control: no-store. This is a black-box test
|
|
// against the real router, exercising whatever middleware chain is
|
|
// wired by RegisterRoutes.
|
|
func TestAPIRoutesEmitNoStoreCacheControl(t *testing.T) {
|
|
_, router := setupTestServer(t)
|
|
|
|
apiPaths := []string{
|
|
"/api/stats",
|
|
"/api/observers",
|
|
"/api/packets?limit=10",
|
|
"/api/nodes?limit=10",
|
|
}
|
|
|
|
for _, p := range apiPaths {
|
|
t.Run(p, func(t *testing.T) {
|
|
req := httptest.NewRequest("GET", p, nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("%s: expected 200, got %d (body: %s)", p, w.Code, w.Body.String())
|
|
}
|
|
cc := w.Header().Get("Cache-Control")
|
|
if cc != "no-store" {
|
|
t.Errorf("%s: expected Cache-Control: no-store, got %q", p, cc)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestStaticAssetsDoNotEmitNoStore guards against scope creep: the
|
|
// no-store middleware must be scoped to /api/* only. Static assets
|
|
// (HTML, JS, CSS) keep their existing browser-cache headers
|
|
// ("no-cache, no-store, must-revalidate" today via spaHandler) and
|
|
// must NOT be downgraded to bare "no-store" by the API middleware —
|
|
// i.e. the API middleware must not run on these paths. If a future
|
|
// change moves static assets behind no-store middleware, CDN caching
|
|
// of immutable hashed assets breaks; assert the contract explicitly.
|
|
func TestStaticAssetsDoNotEmitBareNoStore(t *testing.T) {
|
|
// Build a temp public dir so spaHandler has real files to serve.
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html>SPA</html>"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "app.js"), []byte("console.log('app')"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, router := setupTestServer(t)
|
|
// Wire the SPA handler exactly the way main.go does for non-/api paths.
|
|
fs := http.FileServer(http.Dir(dir))
|
|
router.PathPrefix("/").Handler(spaHandler(dir, fs))
|
|
|
|
cases := []struct {
|
|
path string
|
|
wantCacheCC string
|
|
}{
|
|
// spaHandler sets this exact value for HTML/JS/CSS.
|
|
{"/app.js", "no-cache, no-store, must-revalidate"},
|
|
{"/", "no-cache, no-store, must-revalidate"},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
t.Run(c.path, func(t *testing.T) {
|
|
req := httptest.NewRequest("GET", c.path, nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
cc := w.Header().Get("Cache-Control")
|
|
if cc == "no-store" {
|
|
t.Errorf("%s: API no-store middleware leaked onto static asset (got bare %q, expected %q)", c.path, cc, c.wantCacheCC)
|
|
}
|
|
if cc != c.wantCacheCC {
|
|
t.Errorf("%s: expected Cache-Control %q, got %q", c.path, c.wantCacheCC, cc)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Ensure mux import used (test compiles even if setupTestServer signature
|
|
// changes).
|
|
var _ = mux.NewRouter
|