release: v1.17.0 — implicit offline model + two-tier client-version gate + unified lobby #250

Merged
developer merged 22 commits from development into master 2026-07-13 01:06:35 +00:00
19 changed files with 558 additions and 47 deletions
Showing only changes of commit a57fd355ba - Show all commits
+28 -12
View File
@@ -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).
- **CG — 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.
- **DG — 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 `<UpdateOverlay />` right after `<MaintenanceOverlay />` (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
+17 -16
View File
@@ -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
+57
View File
@@ -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)
}
})
}
}
+20 -8
View File
@@ -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")
}
+23
View File
@@ -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) {
+1
View File
@@ -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.
+66
View File
@@ -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
+91 -7
View File
@@ -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")
+34
View File
@@ -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();
});
+2
View File
@@ -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 @@
<WelcomeRedeemModal />
<Coachmark />
<MaintenanceOverlay />
<UpdateOverlay />
<!-- Cold-start "no connection" dialog: the reachability check timed out with the network interface
reportedly online, so it is ambiguous. The player chooses to go offline (play local vs_ai) or to
+95
View File
@@ -0,0 +1,95 @@
<script lang="ts">
// Non-dismissable "update required" cover. Shown when the gateway has refused a foreground call
// as too old (update.svelte.ts — the client-version gate). Unlike the maintenance overlay this is
// terminal: an installed build cannot become compatible without an actual update, so its one
// action takes the user to the fix — the store listing on a native build (VITE_STORE_URL, opened
// in the system browser / store app) or a plain reload on the web (which fetches the current
// client). Mirrors MaintenanceOverlay.svelte's look.
import { updateRequired } from '../lib/update.svelte';
import { clientChannel } from '../lib/channel';
import { t } from '../lib/i18n/index.svelte';
function onAction(): void {
const ch = clientChannel();
if (ch === 'android' || ch === 'ios') {
// '_system' hands the URL to the OS (the store app / external browser) rather than the WebView.
const url = import.meta.env.VITE_STORE_URL;
if (url) window.open(url, '_system');
} else {
location.reload();
}
}
</script>
{#if updateRequired.active}
<div class="scrim" role="alertdialog" aria-modal="true" aria-labelledby="update-title" aria-describedby="update-body">
<div class="card">
<div class="tile" aria-hidden="true">Э</div>
<h1 id="update-title">{t('update.title')}</h1>
<p id="update-body">{t('update.body')}</p>
<button type="button" onclick={onAction}>{t('update.action')}</button>
</div>
</div>
{/if}
<style>
.scrim {
position: fixed;
inset: 0;
/* Above the game (z 60) and toasts (z 50) — a terminal update block covers everything the user
could otherwise interact with; below the dev DebugPanel (z 10000). */
z-index: 100;
background: rgba(0, 0, 0, 0.55);
display: grid;
place-items: center;
padding: 24px;
box-sizing: border-box;
}
.card {
max-width: 22rem;
text-align: center;
background: var(--surface);
color: var(--text);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 2rem 1.5rem;
}
/* Mirrors a placed board tile (as Splash.svelte / MaintenanceOverlay.svelte do). */
.tile {
display: inline-grid;
place-items: center;
width: 3.5rem;
height: 3.5rem;
font-size: 2rem;
font-weight: 700;
border-radius: 4px;
background: var(--tile-bg);
color: var(--tile-text);
box-shadow:
inset 0 -2px 0 var(--tile-edge),
2px 0 3px -1px rgba(0, 0, 0, 0.4);
margin-bottom: 1.25rem;
}
h1 {
font-size: 1.25rem;
margin: 0 0 0.5rem;
}
p {
margin: 0 0 1.5rem;
color: var(--text-muted);
line-height: 1.5;
}
button {
font: inherit;
padding: 0.55rem 1.4rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: transparent;
color: var(--text);
cursor: pointer;
}
button:hover {
border-color: var(--accent);
color: var(--accent);
}
</style>
+4
View File
@@ -8,6 +8,7 @@ import { createTransport } from './transport';
import { setForcedSeed } from './localgame/id';
import { reportOffline, reportOnline } from './connection.svelte';
import { clearMaintenance, maintenanceRecovered, reportMaintenance } from './maintenance.svelte';
import { reportUpdateRequired } from './update.svelte';
const isMock = import.meta.env.MODE === 'mock';
@@ -31,6 +32,9 @@ if (isMock && typeof window !== 'undefined') {
off: clearMaintenance,
recover: maintenanceRecovered,
};
// The mock never receives a real update_required from the edge, so the e2e drives the terminal
// "update required" overlay directly (it is terminal — there is no clear hook).
(window as unknown as { __update?: { on(): void } }).__update = { on: reportUpdateRequired };
// Drive the auto-match opponent join deterministically from the e2e (the mock otherwise
// attaches a robot on a timer).
(
+3
View File
@@ -10,6 +10,9 @@ export const en = {
'maintenance.title': 'Under maintenance',
'maintenance.body': 'Scrabble is briefly down for an update. It will be back in a moment — no need to reload the page.',
'maintenance.retry': 'Try again',
'update.title': 'Update required',
'update.body': 'This app is out of date and can no longer run. Download the update to keep playing — and winning.',
'update.action': 'Update',
'blocked.title': 'Account blocked',
'blocked.permanent': 'Your account is blocked.',
+3
View File
@@ -11,6 +11,9 @@ export const ru: Record<MessageKey, string> = {
'maintenance.title': 'Технические работы',
'maintenance.body': 'Идёт короткое обновление игры. Мы скоро вернёмся — страницу перезагружать не нужно.',
'maintenance.retry': 'Повторить',
'update.title': 'Требуется обновление',
'update.body': 'Приложение устарело и не может продолжить работу. Загрузите обновлённую версию, чтобы продолжить играть и побеждать.',
'update.action': 'Обновить',
'blocked.title': 'Учётная запись заблокирована',
'blocked.permanent': 'Ваша учётная запись заблокирована.',
+4
View File
@@ -21,6 +21,10 @@ describe('toGatewayError', () => {
expect(toGatewayError(new ConnectError('x', Code.Internal)).code).toBe('internal');
expect(isConnectionCode('internal')).toBe(false);
});
it('maps the Subscribe FailedPrecondition to the update_required sentinel (client too old)', () => {
expect(toGatewayError(new ConnectError('x', Code.FailedPrecondition)).code).toBe('update_required');
});
});
describe('retryable', () => {
+3
View File
@@ -33,6 +33,9 @@ export function toGatewayError(e: unknown): GatewayError {
return new GatewayError('unavailable', e.message);
case Code.NotFound:
return new GatewayError('not_found', e.message);
case Code.FailedPrecondition:
// The Subscribe stream's counterpart of the update_required envelope: the client is too old.
return new GatewayError('update_required', e.message);
default:
return new GatewayError('internal', e.message);
}
+18 -4
View File
@@ -17,6 +17,7 @@ import { offlineMode } from './offline.svelte';
import { maintenanceRecovered, registerMaintenanceProbe, reportMaintenance } from './maintenance.svelte';
import { maintenanceRetryMs } from './maintenance';
import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry';
import { UPDATE_REQUIRED, reportUpdateRequired } from './update.svelte';
const MAX_RETRIES = 6;
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
@@ -34,8 +35,14 @@ export function createTransport(baseUrl: string): GatewayClient {
const client = createClient(Gateway, transport);
let token: string | null = null;
const headers = (): Record<string, string> | undefined =>
token ? { authorization: `Bearer ${token}` } : undefined;
// Every call carries the build version so the edge's client-version gate can turn away a build
// too old to speak the current wire contract, before it decodes the payload (the header rides the
// outermost stable layer; see docs/ARCHITECTURE.md §2).
const headers = (): Record<string, string> => {
const h: Record<string, string> = { 'x-client-version': __APP_VERSION__ };
if (token) h.authorization = `Bearer ${token}`;
return h;
};
// The reachability probe the connection watcher fires while offline: a cheap authenticated read
// (it must reject when there is no session, so the watcher keeps waiting rather than reporting up).
@@ -71,6 +78,8 @@ export function createTransport(baseUrl: string): GatewayClient {
throw toGatewayError(e);
}
const err = toGatewayError(e);
// A too-old client turned away on a foreground call raises the terminal update overlay.
if (err.code === UPDATE_REQUIRED) reportUpdateRequired();
if (retryable(err.code, messageType) && attempt < MAX_RETRIES) {
reportOffline();
await sleep(backoffMs(attempt + 1));
@@ -83,7 +92,10 @@ export function createTransport(baseUrl: string): GatewayClient {
// A read got through: if the maintenance overlay was up, the deploy window has ended —
// reload to pick up the (possibly incompatible) fresh client (maintenance.svelte.ts).
maintenanceRecovered();
if (res.resultCode && res.resultCode !== 'ok') throw new GatewayError(res.resultCode);
if (res.resultCode && res.resultCode !== 'ok') {
if (res.resultCode === UPDATE_REQUIRED) reportUpdateRequired();
throw new GatewayError(res.resultCode);
}
return res.payload;
}
}
@@ -363,7 +375,9 @@ export function createTransport(baseUrl: string): GatewayClient {
if (!ctrl.signal.aborted) {
const maintMs = maintenanceRetryMs(e);
if (maintMs !== null) reportMaintenance(maintMs);
onError?.(toGatewayError(e));
const err = toGatewayError(e);
if (err.code === UPDATE_REQUIRED) reportUpdateRequired();
onError?.(err);
}
}
})();
+26
View File
@@ -0,0 +1,26 @@
// Global "client too old" signal. `active` latches true the first time the gateway answers a
// user-initiated online call with the update_required sentinel — the HTTP-header version gate the
// edge checks before decoding the payload (docs/ARCHITECTURE.md §2). It is terminal: unlike the
// maintenance signal there is no self-clearing poll, because an installed build cannot become
// compatible without an actual update. A non-dismissable overlay (UpdateOverlay.svelte) then covers
// the app; its one action sends the user to the store (native) or reloads (web). Offline play never
// trips it — the network kill switch refuses the call before it leaves the device.
/** UPDATE_REQUIRED is the stable sentinel the gateway returns (the Execute result_code, and the
* GatewayError code the Subscribe FailedPrecondition maps to) for a client too old to be served. */
export const UPDATE_REQUIRED = 'update_required';
let required = $state(false);
export const updateRequired = {
/** active is true once a foreground online call has been refused as too old. Terminal. */
get active(): boolean {
return required;
},
};
/** reportUpdateRequired latches the terminal "update required" overlay. The transport calls it when
* a foreground call returns the update_required sentinel; idempotent. */
export function reportUpdateRequired(): void {
required = true;
}