From b03da903d9aa2abca6dd5717fcb573f98a39661e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 2 May 2026 15:11:10 -0400 Subject: [PATCH] feat(server): /healthz returns min_client_version Lets the M7 Flutter client refuse to operate against an incompatible server (e.g., new endpoints the client depends on, breaking schema shifts). The version constant is bumped on each release where the client/server contract changes; old clients show the user a blocking "update required" modal pointed at the latest APK / TestFlight build. --- internal/server/server.go | 5 ++++- internal/server/server_test.go | 27 +++++++++++++++++++++++++++ internal/server/version.go | 6 ++++++ 3 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 internal/server/version.go diff --git a/internal/server/server.go b/internal/server/server.go index 0a29ad5c..a54936b3 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -103,7 +103,10 @@ func (s *Server) Router() http.Handler { func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + _ = json.NewEncoder(w).Encode(map[string]string{ + "status": "ok", + "min_client_version": MinClientVersion, + }) } // handleAdminScan runs a scan synchronously and returns the resulting stats. diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 3abcc3d3..fdc0ea2a 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -11,6 +11,7 @@ import ( "strings" "testing" + "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/auth" @@ -45,6 +46,32 @@ func TestHealthz(t *testing.T) { } } +func TestHealthz_IncludesMinClientVersion(t *testing.T) { + t.Parallel() + s := &Server{} + r := chi.NewRouter() + r.Get("/healthz", s.handleHealthz) + ts := httptest.NewServer(r) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/healthz") + if err != nil { + t.Fatalf("GET /healthz: %v", err) + } + defer resp.Body.Close() + + var body map[string]string + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body["status"] != "ok" { + t.Errorf("status: got %q want \"ok\"", body["status"]) + } + if body["min_client_version"] == "" { + t.Error("min_client_version is empty; clients can't enforce pairing") + } +} + func TestRouter_ServesSPAAtRoot(t *testing.T) { s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}) ts := httptest.NewServer(s.Router()) diff --git a/internal/server/version.go b/internal/server/version.go new file mode 100644 index 00000000..b97672d0 --- /dev/null +++ b/internal/server/version.go @@ -0,0 +1,6 @@ +package server + +// MinClientVersion is the lowest mobile-client semver that this server +// accepts. Bump it when a server change requires a paired client update; +// older clients see version_too_old at /healthz and refuse to operate. +const MinClientVersion = "0.1.0"