feat(gateway,ui): client-version gate — turn away too-old builds
Introduce a minimum-supported-client gate so a future incompatible wire change can turn away installed builds too old to speak it, cleanly, instead of letting them crash on decode. It rides the outermost stable layer (an HTTP header), never the FlatBuffers payload. Gateway: - New internal/clientver: dependency-free parse + compare of the leading MAJOR.MINOR.PATCH (a git-describe suffix is tolerated). - GATEWAY_MIN_CLIENT_VERSION config (empty => gate dormant; validated at load). - connectsrv checks the X-Client-Version header before decoding the payload: Execute returns result_code="update_required" (before the registry lookup), Subscribe returns FailedPrecondition. It fails open on an absent or garbled header — the header is a client-controlled compatibility signal, not an access control. Client: - Attach X-Client-Version on every call. - A terminal update.svelte.ts store + a non-dismissable UpdateOverlay (native opens VITE_STORE_URL, web reloads); retry.ts maps FailedPrecondition to the update_required sentinel; a mock __update hook drives the e2e. Wire-additive and contour-safe: no FBS/proto regen, no schema migration; the gate stays dormant until GATEWAY_MIN_CLIENT_VERSION is deliberately set, so web / VK / Telegram behaviour is unchanged. The silent reconciliation seam is deferred to the offline-first work (its only caller). Tests: Go clientver/config/connectsrv gate tests, retry.test.ts, Playwright update.spec.ts.
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
// Package clientver parses and compares the leading MAJOR.MINOR.PATCH of a client
|
||||
// version string so the edge can turn away a build too old to speak the current wire
|
||||
// contract. It is deliberately dependency-free and tolerant: the version rides an HTTP
|
||||
// header (X-Client-Version) that a build stamps from `git describe --tags`, so any
|
||||
// `-N-gSHA` or `+meta` suffix is ignored, and anything unparseable is reported as such
|
||||
// (the caller fails open — an absent or garbled header is never treated as too old).
|
||||
package clientver
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Version is a parsed semantic version triple. Only MAJOR.MINOR.PATCH participate in the
|
||||
// ordering; any pre-release or build suffix is dropped at parse time.
|
||||
type Version struct {
|
||||
Major, Minor, Patch int
|
||||
}
|
||||
|
||||
// Parse extracts the leading MAJOR.MINOR.PATCH from s, tolerating an optional leading
|
||||
// "v" and any `-N-gSHA` or `+meta` suffix (as produced by `git describe --tags`). It
|
||||
// reports ok=false when s has fewer than three numeric components or any component is not
|
||||
// an integer, so the caller can distinguish a real version from a dev/empty string.
|
||||
func Parse(s string) (Version, bool) {
|
||||
s = strings.TrimPrefix(strings.TrimSpace(s), "v")
|
||||
if i := strings.IndexAny(s, "-+"); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
p := strings.SplitN(s, ".", 4)
|
||||
if len(p) < 3 {
|
||||
return Version{}, false
|
||||
}
|
||||
var v Version
|
||||
var err error
|
||||
if v.Major, err = strconv.Atoi(p[0]); err != nil {
|
||||
return Version{}, false
|
||||
}
|
||||
if v.Minor, err = strconv.Atoi(p[1]); err != nil {
|
||||
return Version{}, false
|
||||
}
|
||||
if v.Patch, err = strconv.Atoi(p[2]); err != nil {
|
||||
return Version{}, false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
// Less reports whether a orders before b by MAJOR, then MINOR, then PATCH. Equal versions
|
||||
// are not Less than each other, so a client exactly at the minimum passes the gate.
|
||||
func Less(a, b Version) bool {
|
||||
if a.Major != b.Major {
|
||||
return a.Major < b.Major
|
||||
}
|
||||
if a.Minor != b.Minor {
|
||||
return a.Minor < b.Minor
|
||||
}
|
||||
return a.Patch < b.Patch
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package clientver
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestParse covers the version strings the edge actually sees: a plain and a "v"-prefixed
|
||||
// triple, a `git describe --tags` suffix, build metadata, surrounding space, and the
|
||||
// non-version strings (dev/empty/too-few/non-numeric) that must report ok=false so the
|
||||
// gate fails open.
|
||||
func TestParse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want Version
|
||||
wantK bool
|
||||
}{
|
||||
{"plain", "1.16.0", Version{1, 16, 0}, true},
|
||||
{"v-prefixed", "v1.16.0", Version{1, 16, 0}, true},
|
||||
{"git describe suffix", "v1.16.0-3-gabc1234", Version{1, 16, 0}, true},
|
||||
{"build metadata", "1.16.0+ci42", Version{1, 16, 0}, true},
|
||||
{"surrounding space", " v2.0.1 ", Version{2, 0, 1}, true},
|
||||
{"extra component ignored", "v1.16.0.4", Version{1, 16, 0}, true},
|
||||
{"dev", "dev", Version{}, false},
|
||||
{"empty", "", Version{}, false},
|
||||
{"too few components", "1.16", Version{}, false},
|
||||
{"non-numeric patch", "1.16.x", Version{}, false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, ok := Parse(tc.in)
|
||||
if ok != tc.wantK {
|
||||
t.Fatalf("Parse(%q) ok = %v, want %v", tc.in, ok, tc.wantK)
|
||||
}
|
||||
if ok && got != tc.want {
|
||||
t.Errorf("Parse(%q) = %+v, want %+v", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLess covers the ordering by MAJOR, then MINOR, then PATCH, and that an equal version
|
||||
// is not Less (a client exactly at the minimum passes the gate).
|
||||
func TestLess(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
a, b Version
|
||||
want bool
|
||||
}{
|
||||
{"equal", Version{1, 16, 0}, Version{1, 16, 0}, false},
|
||||
{"major less", Version{1, 9, 9}, Version{2, 0, 0}, true},
|
||||
{"major greater", Version{2, 0, 0}, Version{1, 9, 9}, false},
|
||||
{"minor less", Version{1, 16, 5}, Version{1, 17, 0}, true},
|
||||
{"minor greater", Version{1, 17, 0}, Version{1, 16, 9}, false},
|
||||
{"patch less", Version{1, 16, 0}, Version{1, 16, 1}, true},
|
||||
{"patch greater", Version{1, 16, 2}, Version{1, 16, 1}, false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := Less(tc.a, tc.b); got != tc.want {
|
||||
t.Errorf("Less(%+v, %+v) = %v, want %v", tc.a, tc.b, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user