diff --git a/ANDROID_PLAN.md b/ANDROID_PLAN.md
index 0312637..97791da 100644
--- a/ANDROID_PLAN.md
+++ b/ANDROID_PLAN.md
@@ -81,7 +81,17 @@ Kept current as parts land so a fresh session resumes without re-deriving. Verif
`vite-env.d.ts`. `svelte-check` 0 errors, `vitest` 590 passed, web + native `vite build` clean;
`?nopay`/`?gp` wallet states verified live via the Playwright MCP browser (the e2e runner can't fetch
browsers in this sandbox — the states are covered by `e2e/wallet.spec.ts`, which CI runs).
-- **C–G — pending.**
+- **C. Client-version gate — ✅ DONE & verified (2026-07-12).** Server: new `gateway/internal/clientver`
+ (parse + compare), `GATEWAY_MIN_CLIENT_VERSION` config (empty ⇒ dormant, validated at load), and the
+ gate in `connectsrv` — `Execute` returns `result_code="update_required"` before the registry lookup,
+ `Subscribe` returns `FailedPrecondition`; fail-open on an absent/garbled header. Client:
+ `X-Client-Version` on every call (`transport.ts headers()`), a terminal `update.svelte.ts` store +
+ `UpdateOverlay.svelte` (native → `VITE_STORE_URL`, web → reload), `retry.ts` maps
+ `FailedPrecondition → update_required`, the `__update` mock hook. `gofmt`/`vet` clean, Go
+ `clientver`/config/`connectsrv` tests green, `svelte-check` 0, `vitest` 591, e2e 232 (incl.
+ `update.spec.ts`), client build clean. Silent reconciliation seam deferred to D (owner); in-app
+ store-update SDK noted Out of scope.
+- **D–G — pending.**
Open logistics (not code): **mandatory icon rebrand (future)** — author ONE vector master and generate
*every* icon from it (web `favicon.svg` / PWA `icon-*` / maskable, Android adaptive, future iOS). Today
@@ -287,7 +297,7 @@ is: scaffold → native-correct → gate → offline-first → CI → docs → r
**Done when:** a native-flavoured build reaches the gateway, signs in as guest, plays a move, and the
purchase actions are absent; `pnpm check` + `pnpm test:unit` pass.
-### C. The client-version gate
+### C. The client-version gate — ✅ DONE
#### C1. Backend (gateway)
@@ -364,10 +374,10 @@ purchase actions are absent; `pnpm check` + `pnpm test:unit` pass.
before the throw.
- catch (after `toGatewayError`, line 73) and the `subscribe()` catch: if the code is
`UPDATE_REQUIRED`, call `reportUpdateRequired()` before rethrowing/`onError`.
- - **Reconciliation exception:** provide a variant used by background guest-reconciliation that does
- NOT call `reportUpdateRequired()` (it swallows the code and stays offline). Simplest: a boolean
- on `exec`/a dedicated `authGuestSilent` path guarded by a flag; the executing session picks the
- seam. Foreground `auth.*` keep the overlay behaviour.
+ - **Reconciliation exception — deferred to D (owner decision).** The silent update-path variant
+ (swallows `update_required` without raising the overlay) has no caller until D.4, so it is added
+ there with its caller rather than speculatively in C. In C every foreground call that gets
+ `update_required` raises the overlay; offline play never trips it (the network kill switch).
- **`ui/src/lib/retry.ts` `toGatewayError()`**: add
`case Code.FailedPrecondition: return new GatewayError('update_required', e.message);`.
- **`ui/src/components/UpdateOverlay.svelte`** (new, mirror `MaintenanceOverlay.svelte`): non-dismissable;
@@ -376,8 +386,10 @@ purchase actions are absent; `pnpm check` + `pnpm test:unit` pass.
`update.title/body/action` (add siblings to the maintenance keys).
- **`ui/src/App.svelte`**: import + place `` right after `` (line 139).
- **Mock e2e hook** — `ui/src/lib/gateway.ts` mock branch, next to `__maint`: `__update = { on: reportUpdateRequired }`.
-- **Tests:** `update.svelte.test.ts`; extend `retry.test.ts` (`FailedPrecondition → 'update_required'`);
- Playwright `update.spec.ts` driving `__update.on()`.
+- **Tests:** extend `retry.test.ts` (`FailedPrecondition → 'update_required'`) and drive the overlay via
+ Playwright `update.spec.ts` (`__update.on()`). The `update.svelte.ts` store is a `$state` rune module,
+ which this project's plugin-less `vitest` cannot import (`$state is not defined`); like every other
+ `*.svelte.ts` store it is covered by the e2e, not a unit test.
**Done when:** Go `clientver` + gate tests pass; `pnpm check`/`test:unit`/`test:e2e` pass; a local
gateway with `GATEWAY_MIN_CLIENT_VERSION=v99.0.0` shows the overlay, unset ⇒ unchanged.
@@ -425,9 +437,10 @@ with no network**.
`standalone && hasEmail` web gate for native), or bypass preload entirely on native since the
dicts are bundled.
4. **Lazy server-guest reconciliation** — when native and online and no server session: background
- `auth.guest` (the silent variant from C2), cache the session, adopt it; unlock online features.
- Guard against duplicates (only when no cached session). Local games remain device-only. On
- `update_required`, stay offline silently (no overlay).
+ `auth.guest`, cache the session, adopt it; unlock online features. Guard against duplicates (only
+ when no cached session). Local games remain device-only. **Add the silent update-path variant here**
+ (a flag on the transport `exec`/a dedicated silent `auth.guest`) so reconciliation swallows
+ `update_required` and stays offline — no overlay. This is the seam deferred from C.
5. **Ensure both offline modes render for the local guest.** `NewGame.svelte` gates the offline
flows on `guest || offlineMode.active`; the local guest makes `offlineMode.active` true, so both
the "quick" (vs_ai) and "with friends" (hotseat) flows show. Verify the vs_ai human seat uses the
@@ -576,7 +589,10 @@ breaking release deliberately sets the min version.
Google Play publication (beyond keeping the codebase variant-ready), iOS, OTA/live-updates, in-app
purchases in the native build (RuStore billing SDK) and the GP anti-steering fix, VK/Telegram login in
-the native build, a soft "recommended update" tier, native splash/status-bar plugins, push
+the native build, a soft "recommended update" tier, **in-app store-update flows** (Google Play In-App
+Updates API / RuStore In-App Update SDK — a future enhancement that would swap the update overlay's
+action from opening the store listing to a store-driven immediate in-app update; needs a Capacitor
+plugin bridge, and is orthogonal to the server-driven gate), native splash/status-bar plugins, push
notifications (FCM), and automated RuStore-API upload.
## End-to-end verification
diff --git a/gateway/cmd/gateway/main.go b/gateway/cmd/gateway/main.go
index b0f3745..a3e92f7 100644
--- a/gateway/cmd/gateway/main.go
+++ b/gateway/cmd/gateway/main.go
@@ -221,22 +221,23 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
}
registry := transcode.NewRegistry(backend, validator, regOpts...)
edge := connectsrv.NewServer(connectsrv.Deps{
- Registry: registry,
- Sessions: sessions,
- Backend: backend,
- Limiter: limiter,
- Tracker: tracker,
- Banlist: banlist,
- Blocklist: blocklist,
- Honeytoken: cfg.Abuse.Honeytoken,
- VKAppSecret: cfg.VKAppSecret,
- Hub: hub,
- RateLimit: cfg.RateLimit,
- Heartbeat: cfg.PushHeartbeatInterval,
- Logger: logger,
- AdminProxy: adminProxy,
- Meter: tel.MeterProvider().Meter("scrabble/gateway/edge"),
- MaxBodyBytes: cfg.MaxBodyBytes,
+ Registry: registry,
+ Sessions: sessions,
+ Backend: backend,
+ Limiter: limiter,
+ Tracker: tracker,
+ Banlist: banlist,
+ Blocklist: blocklist,
+ Honeytoken: cfg.Abuse.Honeytoken,
+ VKAppSecret: cfg.VKAppSecret,
+ Hub: hub,
+ RateLimit: cfg.RateLimit,
+ Heartbeat: cfg.PushHeartbeatInterval,
+ Logger: logger,
+ AdminProxy: adminProxy,
+ Meter: tel.MeterProvider().Meter("scrabble/gateway/edge"),
+ MaxBodyBytes: cfg.MaxBodyBytes,
+ MinClientVersion: cfg.MinClientVersion,
})
// Bridge the backend push stream into the fan-out hub (and the out-of-app
diff --git a/gateway/internal/clientver/clientver.go b/gateway/internal/clientver/clientver.go
new file mode 100644
index 0000000..a0efc2e
--- /dev/null
+++ b/gateway/internal/clientver/clientver.go
@@ -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
+}
diff --git a/gateway/internal/clientver/clientver_test.go b/gateway/internal/clientver/clientver_test.go
new file mode 100644
index 0000000..22ccd81
--- /dev/null
+++ b/gateway/internal/clientver/clientver_test.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/gateway/internal/config/config.go b/gateway/internal/config/config.go
index 2080c48..3ff7354 100644
--- a/gateway/internal/config/config.go
+++ b/gateway/internal/config/config.go
@@ -9,6 +9,7 @@ import (
"strings"
"time"
+ "scrabble/gateway/internal/clientver"
pkgtel "scrabble/pkg/telemetry"
)
@@ -54,6 +55,11 @@ type Config struct {
// MaxBodyBytes caps one inbound request body on the public listener and one
// Connect message read; oversized requests are refused without buffering.
MaxBodyBytes int
+ // MinClientVersion, when non-empty, is the lowest client version (MAJOR.MINOR.PATCH)
+ // the edge serves. A client reporting an older X-Client-Version is turned away with an
+ // "update required" signal before its payload is decoded. Empty leaves the gate dormant
+ // (every client is served) — the default for web-only deployments.
+ MinClientVersion string
// RateLimit configures the in-memory anti-abuse limiter.
RateLimit RateLimitConfig
// Abuse configures the temporary IP ban and the honeytoken (prod-only).
@@ -226,14 +232,15 @@ func (c VKIDConfig) Enabled() bool {
func Load() (Config, error) {
var err error
c := Config{
- HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr),
- LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel),
- BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL),
- BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr),
- AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
- AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
- ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
- VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
+ HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr),
+ LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel),
+ BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL),
+ BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr),
+ AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
+ AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
+ ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
+ VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
+ MinClientVersion: os.Getenv("GATEWAY_MIN_CLIENT_VERSION"),
VKID: VKIDConfig{
AppID: os.Getenv("GATEWAY_VK_ID_APP_ID"),
ClientSecret: os.Getenv("GATEWAY_VK_ID_CLIENT_SECRET"),
@@ -332,6 +339,11 @@ func (c Config) validate() error {
if c.MaxBodyBytes <= 0 {
return fmt.Errorf("config: GATEWAY_MAX_BODY_BYTES must be positive")
}
+ if c.MinClientVersion != "" {
+ if _, ok := clientver.Parse(c.MinClientVersion); !ok {
+ return fmt.Errorf("config: GATEWAY_MIN_CLIENT_VERSION %q is not a MAJOR.MINOR.PATCH version", c.MinClientVersion)
+ }
+ }
if c.Abuse.BanEnabled && (c.Abuse.BanThreshold <= 0 || c.Abuse.BanWindow <= 0 || c.Abuse.BanDuration <= 0) {
return fmt.Errorf("config: GATEWAY_ABUSE_BAN_THRESHOLD/_WINDOW/_DURATION must be positive when GATEWAY_ABUSE_BAN_ENABLED")
}
diff --git a/gateway/internal/config/config_test.go b/gateway/internal/config/config_test.go
index 60ff87c..73bf800 100644
--- a/gateway/internal/config/config_test.go
+++ b/gateway/internal/config/config_test.go
@@ -47,6 +47,29 @@ func TestLoadMaxBodyBytes(t *testing.T) {
}
}
+// TestLoadMinClientVersion verifies the client-version gate config: dormant (empty) by
+// default, a parseable version accepted, and an unparseable one rejected.
+func TestLoadMinClientVersion(t *testing.T) {
+ c, err := Load()
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ if c.MinClientVersion != "" {
+ t.Errorf("MinClientVersion = %q, want empty (gate dormant)", c.MinClientVersion)
+ }
+ t.Setenv("GATEWAY_MIN_CLIENT_VERSION", "v1.16.0")
+ if c, err = Load(); err != nil {
+ t.Fatalf("Load with a valid min version: %v", err)
+ }
+ if c.MinClientVersion != "v1.16.0" {
+ t.Errorf("MinClientVersion = %q, want %q", c.MinClientVersion, "v1.16.0")
+ }
+ t.Setenv("GATEWAY_MIN_CLIENT_VERSION", "dev")
+ if _, err := Load(); err == nil {
+ t.Fatal("Load: expected an error for an unparseable GATEWAY_MIN_CLIENT_VERSION, got nil")
+ }
+}
+
// TestLoadAbuseDefaults verifies the anti-abuse ban defaults: disabled (prod-only),
// the agreed thresholds, and no honeytoken.
func TestLoadAbuseDefaults(t *testing.T) {
diff --git a/gateway/internal/connectsrv/errors.go b/gateway/internal/connectsrv/errors.go
index 68a14c6..838a68e 100644
--- a/gateway/internal/connectsrv/errors.go
+++ b/gateway/internal/connectsrv/errors.go
@@ -12,6 +12,7 @@ var (
errInternal = errors.New("internal error")
errMissingToken = errors.New("missing session token")
errInvalidSession = errors.New("invalid or expired session")
+ errUpdateRequired = errors.New("client too old, update required")
)
// errUnknownMessageType reports an unregistered message type.
diff --git a/gateway/internal/connectsrv/server.go b/gateway/internal/connectsrv/server.go
index c8c9a52..c3c4853 100644
--- a/gateway/internal/connectsrv/server.go
+++ b/gateway/internal/connectsrv/server.go
@@ -26,6 +26,7 @@ import (
"golang.org/x/net/http2/h2c"
"scrabble/gateway/internal/backendclient"
+ "scrabble/gateway/internal/clientver"
"scrabble/gateway/internal/config"
"scrabble/gateway/internal/push"
"scrabble/gateway/internal/ratelimit"
@@ -46,6 +47,16 @@ const heartbeatKind = "heartbeat"
// spoofer, so trusting it is safe.
const honeypotHeader = "X-Scrabble-Honeypot"
+// clientVersionHeader carries the client build version (stamped from `git describe --tags`) so
+// the edge can turn away a build too old to speak the current wire contract, before it decodes
+// the payload. resultUpdateRequired is the stable envelope result_code — with the Subscribe
+// counterpart connect.CodeFailedPrecondition — that any build, however old, recognises as
+// "you must update". Both are part of the frozen wire contract (docs/ARCHITECTURE.md §2).
+const (
+ clientVersionHeader = "X-Client-Version"
+ resultUpdateRequired = "update_required"
+)
+
// Limiter classes, the `class` attribute of gateway_rate_limited_total and the
// class field of the periodic rejection report.
const (
@@ -88,6 +99,11 @@ type Server struct {
maxBodyBytes int
+ // minClient is the lowest client version served; gateOn is false when the gate is dormant
+ // (GATEWAY_MIN_CLIENT_VERSION empty or unparseable).
+ minClient clientver.Version
+ gateOn bool
+
publicPolicy ratelimit.Policy
userPolicy ratelimit.Policy
emailPolicy ratelimit.Policy
@@ -128,6 +144,10 @@ type Deps struct {
// MaxBodyBytes caps one inbound request body and one Connect message read;
// zero or negative selects config.DefaultMaxBodyBytes.
MaxBodyBytes int
+ // MinClientVersion is the lowest client version (MAJOR.MINOR.PATCH) the edge serves; an
+ // older X-Client-Version is turned away with "update required". Empty or unparseable leaves
+ // the gate dormant.
+ MinClientVersion string
}
// NewServer constructs the edge service.
@@ -160,6 +180,18 @@ func NewServer(d Deps) *Server {
if rl == (config.RateLimitConfig{}) {
rl = config.DefaultRateLimit()
}
+ // Parse the minimum client version once. Config.validate already rejects an unparseable
+ // value, so the warn branch only guards a direct (test) construction; an empty value leaves
+ // the gate dormant.
+ var minClient clientver.Version
+ gateOn := false
+ if d.MinClientVersion != "" {
+ if v, ok := clientver.Parse(d.MinClientVersion); ok {
+ minClient, gateOn = v, true
+ } else {
+ log.Warn("ignoring unparseable MinClientVersion; client-version gate disabled", zap.String("value", d.MinClientVersion))
+ }
+ }
return &Server{
registry: d.Registry,
sessions: d.Sessions,
@@ -176,6 +208,8 @@ func NewServer(d Deps) *Server {
adminProxy: d.AdminProxy,
metrics: newServerMetrics(d.Meter, blocklist),
maxBodyBytes: maxBody,
+ minClient: minClient,
+ gateOn: gateOn,
publicPolicy: ratelimit.PerMinute(rl.PublicPerMinute, rl.PublicBurst),
userPolicy: ratelimit.PerMinute(rl.UserPerMinute, rl.UserBurst),
emailPolicy: ratelimit.Per(rl.EmailPer10Min, 10*time.Minute, rl.EmailBurst),
@@ -285,6 +319,22 @@ func (s *Server) abuseGuard(next http.Handler) http.Handler {
})
}
+// clientTooOld reports whether the X-Client-Version header names a version below the configured
+// minimum. It fails open: with the gate dormant, or an absent or unparseable header, it returns
+// false. The header is a client-controlled compatibility signal, not an access control (it is
+// trivially spoofable), so a missing or garbled value — an old build predating the header, or a
+// non-browser caller — must never be blocked spuriously.
+func (s *Server) clientTooOld(header string) bool {
+ if !s.gateOn {
+ return false
+ }
+ v, ok := clientver.Parse(header)
+ if !ok {
+ return false
+ }
+ return clientver.Less(v, s.minClient)
+}
+
// Execute runs one unary operation. Domain failures are returned in the envelope
// (result_code != "ok", HTTP 200); only edge failures (rate limit, missing
// session, unknown type, internal) become Connect errors.
@@ -294,6 +344,17 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
result := "internal"
defer func() { s.metrics.recordEdge(ctx, msgType, result, start) }()
+ // The version gate rides the outermost stable layer (an HTTP header) and is checked before
+ // the payload is decoded, so a too-old client makes zero successful calls but sees the
+ // recognizable update_required envelope rather than a decode crash (docs/ARCHITECTURE.md §2).
+ if s.clientTooOld(req.Header().Get(clientVersionHeader)) {
+ result = resultUpdateRequired
+ return connect.NewResponse(&edgev1.ExecuteResponse{
+ RequestId: req.Msg.GetRequestId(),
+ ResultCode: resultUpdateRequired,
+ }), nil
+ }
+
op, ok := s.registry.Lookup(msgType)
if !ok {
result = "unknown_type"
@@ -363,6 +424,11 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
// Subscribe streams the authenticated user's live events with a keep-alive
// heartbeat until the client disconnects.
func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.SubscribeRequest], stream *connect.ServerStream[edgev1.Event]) error {
+ // A stream carries no result_code, so a too-old client is refused with the Connect
+ // FailedPrecondition counterpart of the update_required sentinel.
+ if s.clientTooOld(req.Header().Get(clientVersionHeader)) {
+ return connect.NewError(connect.CodeFailedPrecondition, errUpdateRequired)
+ }
uid, _, _, err := s.resolve(ctx, req.Header(), peerIP(req.Peer().Addr, req.Header()))
if err != nil {
return err
diff --git a/gateway/internal/connectsrv/server_test.go b/gateway/internal/connectsrv/server_test.go
index 17dc3bc..45cca2a 100644
--- a/gateway/internal/connectsrv/server_test.go
+++ b/gateway/internal/connectsrv/server_test.go
@@ -22,8 +22,13 @@ import (
)
// newEdge wires a connectsrv.Server over a fake backend and returns a Connect
-// client plus a cleanup func.
+// client plus a cleanup func. The client-version gate is off.
func newEdge(t *testing.T, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) {
+ return newEdgeMin(t, "", backendHandler)
+}
+
+// newEdgeMin is newEdge with the client-version gate armed at minVersion (empty leaves it off).
+func newEdgeMin(t *testing.T, minVersion string, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) {
t.Helper()
backendSrv := httptest.NewServer(backendHandler)
backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second)
@@ -31,12 +36,13 @@ func newEdge(t *testing.T, backendHandler http.HandlerFunc) (edgev1connect.Gatew
t.Fatalf("backendclient: %v", err)
}
edge := connectsrv.NewServer(connectsrv.Deps{
- Registry: transcode.NewRegistry(backend, nil),
- Sessions: session.NewCache(backend, time.Minute, 100),
- Limiter: ratelimit.New(),
- Hub: push.NewHub(0),
- RateLimit: config.DefaultRateLimit(),
- Heartbeat: 15 * time.Second,
+ Registry: transcode.NewRegistry(backend, nil),
+ Sessions: session.NewCache(backend, time.Minute, 100),
+ Limiter: ratelimit.New(),
+ Hub: push.NewHub(0),
+ RateLimit: config.DefaultRateLimit(),
+ Heartbeat: 15 * time.Second,
+ MinClientVersion: minVersion,
})
edgeSrv := httptest.NewServer(edge.HTTPHandler())
client := edgev1connect.NewGatewayClient(http.DefaultClient, edgeSrv.URL)
@@ -108,6 +114,84 @@ func TestExecuteGuestGate(t *testing.T) {
}
}
+// TestExecuteVersionGate verifies the client-version gate on the unary path: a build older
+// than the configured minimum is turned away with the update_required result code before the
+// registry lookup (an unknown message type is gated too, proving the short-circuit), while an
+// equal, newer, absent, or unparseable version passes; and the gate is dormant when unset.
+func TestExecuteVersionGate(t *testing.T) {
+ // The backend answers auth.guest with a session; a gated call never reaches it.
+ backend := func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
+ }
+ tests := []struct {
+ name string
+ min string
+ header string
+ msgType string
+ want string
+ }{
+ {"too old is gated before lookup", "v1.16.0", "v1.0.0", "bogus.unknown", "update_required"},
+ {"equal passes", "v1.16.0", "v1.16.0", transcode.MsgAuthGuest, "ok"},
+ {"newer passes", "v1.16.0", "v2.0.0", transcode.MsgAuthGuest, "ok"},
+ {"absent header fails open", "v1.16.0", "", transcode.MsgAuthGuest, "ok"},
+ {"unparseable header fails open", "v1.16.0", "dev", transcode.MsgAuthGuest, "ok"},
+ {"gate dormant when unset", "", "v1.0.0", transcode.MsgAuthGuest, "ok"},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ client, cleanup := newEdgeMin(t, tc.min, backend)
+ defer cleanup()
+ req := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: tc.msgType, RequestId: "rq"})
+ if tc.header != "" {
+ req.Header().Set("X-Client-Version", tc.header)
+ }
+ resp, err := client.Execute(context.Background(), req)
+ if err != nil {
+ t.Fatalf("execute: %v", err)
+ }
+ if got := resp.Msg.GetResultCode(); got != tc.want {
+ t.Fatalf("result_code = %q, want %q", got, tc.want)
+ }
+ })
+ }
+}
+
+// TestSubscribeVersionGate verifies the streaming path: a too-old client is refused with
+// FailedPrecondition, while an equal or absent version is admitted and proceeds to auth (which
+// fails Unauthenticated with no session, proving it passed the version gate).
+func TestSubscribeVersionGate(t *testing.T) {
+ noBackend := func(_ http.ResponseWriter, _ *http.Request) {}
+ tests := []struct {
+ name string
+ min string
+ header string
+ want connect.Code
+ }{
+ {"too old refused", "v1.16.0", "v1.0.0", connect.CodeFailedPrecondition},
+ {"equal admitted then auth-gated", "v1.16.0", "v1.16.0", connect.CodeUnauthenticated},
+ {"gate dormant admits", "", "v1.0.0", connect.CodeUnauthenticated},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ client, cleanup := newEdgeMin(t, tc.min, noBackend)
+ defer cleanup()
+ req := connect.NewRequest(&edgev1.SubscribeRequest{})
+ if tc.header != "" {
+ req.Header().Set("X-Client-Version", tc.header)
+ }
+ stream, err := client.Subscribe(context.Background(), req)
+ if err == nil {
+ for stream.Receive() {
+ }
+ err = stream.Err()
+ }
+ if got := connect.CodeOf(err); got != tc.want {
+ t.Fatalf("code = %v, want %v", got, tc.want)
+ }
+ })
+ }
+}
+
func TestExecuteAuthedRequiresSession(t *testing.T) {
client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("backend must not be called without a session")
diff --git a/ui/e2e/update.spec.ts b/ui/e2e/update.spec.ts
new file mode 100644
index 0000000..baa7ca3
--- /dev/null
+++ b/ui/e2e/update.spec.ts
@@ -0,0 +1,34 @@
+import { expect, test } from './fixtures';
+
+// The "update required" overlay is raised in prod when the edge's client-version gate turns away a
+// too-old build (the update_required result_code on Execute, a Subscribe FailedPrecondition). The
+// mock transport never produces one, so — like the maintenance overlay — the e2e drives it through
+// the window.__update hook (gateway.ts, mock-only). It is app-global (mounted outside the route
+// blocks in App.svelte), so it shows without a session, and it is terminal (no self-clearing poll).
+test('update overlay covers the app when the client is too old', async ({ page }) => {
+ await page.goto('/');
+ await expect(page.getByRole('alertdialog')).toHaveCount(0);
+
+ await page.evaluate(() => (window as unknown as { __update: { on(): void } }).__update.on());
+ const overlay = page.getByRole('alertdialog');
+ await expect(overlay).toBeVisible();
+ // One action button (EN "Update" / RU "Обновить" depending on locale).
+ await expect(overlay.getByRole('button', { name: /Update|Обновить/i })).toBeVisible();
+});
+
+test('the update action reloads the web client', async ({ page }) => {
+ await page.goto('/');
+ await page.evaluate(() => (window as unknown as { __update: { on(): void } }).__update.on());
+ const overlay = page.getByRole('alertdialog');
+ await expect(overlay).toBeVisible();
+
+ // On the web the action reloads the page to fetch the current client (on a native build it opens
+ // the store instead). The reload resets the terminal store, so the app re-bootstraps: the overlay
+ // is gone and the login is back.
+ await Promise.all([
+ page.waitForEvent('load'),
+ overlay.getByRole('button', { name: /Update|Обновить/i }).click(),
+ ]);
+ await expect(page.getByRole('alertdialog')).toHaveCount(0);
+ await expect(page.getByRole('button', { name: /guest/i })).toBeVisible();
+});
diff --git a/ui/src/App.svelte b/ui/src/App.svelte
index ad9fe2c..5003683 100644
--- a/ui/src/App.svelte
+++ b/ui/src/App.svelte
@@ -11,6 +11,7 @@
import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte';
import Coachmark from './components/Coachmark.svelte';
import MaintenanceOverlay from './components/MaintenanceOverlay.svelte';
+ import UpdateOverlay from './components/UpdateOverlay.svelte';
import DebugPanel from './components/DebugPanel.svelte';
import Login from './screens/Login.svelte';
import Lobby from './screens/Lobby.svelte';
@@ -142,6 +143,7 @@
+