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 @@ +