feat(gateway,ui): client-version gate — turn away too-old builds
Introduce a minimum-supported-client gate so a future incompatible wire change can turn away installed builds too old to speak it, cleanly, instead of letting them crash on decode. It rides the outermost stable layer (an HTTP header), never the FlatBuffers payload. Gateway: - New internal/clientver: dependency-free parse + compare of the leading MAJOR.MINOR.PATCH (a git-describe suffix is tolerated). - GATEWAY_MIN_CLIENT_VERSION config (empty => gate dormant; validated at load). - connectsrv checks the X-Client-Version header before decoding the payload: Execute returns result_code="update_required" (before the registry lookup), Subscribe returns FailedPrecondition. It fails open on an absent or garbled header — the header is a client-controlled compatibility signal, not an access control. Client: - Attach X-Client-Version on every call. - A terminal update.svelte.ts store + a non-dismissable UpdateOverlay (native opens VITE_STORE_URL, web reloads); retry.ts maps FailedPrecondition to the update_required sentinel; a mock __update hook drives the e2e. Wire-additive and contour-safe: no FBS/proto regen, no schema migration; the gate stays dormant until GATEWAY_MIN_CLIENT_VERSION is deliberately set, so web / VK / Telegram behaviour is unchanged. The silent reconciliation seam is deferred to the offline-first work (its only caller). Tests: Go clientver/config/connectsrv gate tests, retry.test.ts, Playwright update.spec.ts.
This commit is contained in:
+28
-12
@@ -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;
|
`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
|
`?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).
|
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
|
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
|
*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
|
**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.
|
purchase actions are absent; `pnpm check` + `pnpm test:unit` pass.
|
||||||
|
|
||||||
### C. The client-version gate
|
### C. The client-version gate — ✅ DONE
|
||||||
|
|
||||||
#### C1. Backend (gateway)
|
#### C1. Backend (gateway)
|
||||||
|
|
||||||
@@ -364,10 +374,10 @@ purchase actions are absent; `pnpm check` + `pnpm test:unit` pass.
|
|||||||
before the throw.
|
before the throw.
|
||||||
- catch (after `toGatewayError`, line 73) and the `subscribe()` catch: if the code is
|
- catch (after `toGatewayError`, line 73) and the `subscribe()` catch: if the code is
|
||||||
`UPDATE_REQUIRED`, call `reportUpdateRequired()` before rethrowing/`onError`.
|
`UPDATE_REQUIRED`, call `reportUpdateRequired()` before rethrowing/`onError`.
|
||||||
- **Reconciliation exception:** provide a variant used by background guest-reconciliation that does
|
- **Reconciliation exception — deferred to D (owner decision).** The silent update-path variant
|
||||||
NOT call `reportUpdateRequired()` (it swallows the code and stays offline). Simplest: a boolean
|
(swallows `update_required` without raising the overlay) has no caller until D.4, so it is added
|
||||||
on `exec`/a dedicated `authGuestSilent` path guarded by a flag; the executing session picks the
|
there with its caller rather than speculatively in C. In C every foreground call that gets
|
||||||
seam. Foreground `auth.*` keep the overlay behaviour.
|
`update_required` raises the overlay; offline play never trips it (the network kill switch).
|
||||||
- **`ui/src/lib/retry.ts` `toGatewayError()`**: add
|
- **`ui/src/lib/retry.ts` `toGatewayError()`**: add
|
||||||
`case Code.FailedPrecondition: return new GatewayError('update_required', e.message);`.
|
`case Code.FailedPrecondition: return new GatewayError('update_required', e.message);`.
|
||||||
- **`ui/src/components/UpdateOverlay.svelte`** (new, mirror `MaintenanceOverlay.svelte`): non-dismissable;
|
- **`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).
|
`update.title/body/action` (add siblings to the maintenance keys).
|
||||||
- **`ui/src/App.svelte`**: import + place `<UpdateOverlay />` right after `<MaintenanceOverlay />` (line 139).
|
- **`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 }`.
|
- **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'`);
|
- **Tests:** extend `retry.test.ts` (`FailedPrecondition → 'update_required'`) and drive the overlay via
|
||||||
Playwright `update.spec.ts` driving `__update.on()`.
|
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
|
**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.
|
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
|
`standalone && hasEmail` web gate for native), or bypass preload entirely on native since the
|
||||||
dicts are bundled.
|
dicts are bundled.
|
||||||
4. **Lazy server-guest reconciliation** — when native and online and no server session: background
|
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.
|
`auth.guest`, cache the session, adopt it; unlock online features. Guard against duplicates (only
|
||||||
Guard against duplicates (only when no cached session). Local games remain device-only. On
|
when no cached session). Local games remain device-only. **Add the silent update-path variant here**
|
||||||
`update_required`, stay offline silently (no overlay).
|
(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
|
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
|
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
|
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
|
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
|
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.
|
notifications (FCM), and automated RuStore-API upload.
|
||||||
|
|
||||||
## End-to-end verification
|
## End-to-end verification
|
||||||
|
|||||||
+17
-16
@@ -221,22 +221,23 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
}
|
}
|
||||||
registry := transcode.NewRegistry(backend, validator, regOpts...)
|
registry := transcode.NewRegistry(backend, validator, regOpts...)
|
||||||
edge := connectsrv.NewServer(connectsrv.Deps{
|
edge := connectsrv.NewServer(connectsrv.Deps{
|
||||||
Registry: registry,
|
Registry: registry,
|
||||||
Sessions: sessions,
|
Sessions: sessions,
|
||||||
Backend: backend,
|
Backend: backend,
|
||||||
Limiter: limiter,
|
Limiter: limiter,
|
||||||
Tracker: tracker,
|
Tracker: tracker,
|
||||||
Banlist: banlist,
|
Banlist: banlist,
|
||||||
Blocklist: blocklist,
|
Blocklist: blocklist,
|
||||||
Honeytoken: cfg.Abuse.Honeytoken,
|
Honeytoken: cfg.Abuse.Honeytoken,
|
||||||
VKAppSecret: cfg.VKAppSecret,
|
VKAppSecret: cfg.VKAppSecret,
|
||||||
Hub: hub,
|
Hub: hub,
|
||||||
RateLimit: cfg.RateLimit,
|
RateLimit: cfg.RateLimit,
|
||||||
Heartbeat: cfg.PushHeartbeatInterval,
|
Heartbeat: cfg.PushHeartbeatInterval,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
AdminProxy: adminProxy,
|
AdminProxy: adminProxy,
|
||||||
Meter: tel.MeterProvider().Meter("scrabble/gateway/edge"),
|
Meter: tel.MeterProvider().Meter("scrabble/gateway/edge"),
|
||||||
MaxBodyBytes: cfg.MaxBodyBytes,
|
MaxBodyBytes: cfg.MaxBodyBytes,
|
||||||
|
MinClientVersion: cfg.MinClientVersion,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Bridge the backend push stream into the fan-out hub (and the out-of-app
|
// Bridge the backend push stream into the fan-out hub (and the out-of-app
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"scrabble/gateway/internal/clientver"
|
||||||
pkgtel "scrabble/pkg/telemetry"
|
pkgtel "scrabble/pkg/telemetry"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -54,6 +55,11 @@ type Config struct {
|
|||||||
// MaxBodyBytes caps one inbound request body on the public listener and one
|
// MaxBodyBytes caps one inbound request body on the public listener and one
|
||||||
// Connect message read; oversized requests are refused without buffering.
|
// Connect message read; oversized requests are refused without buffering.
|
||||||
MaxBodyBytes int
|
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 configures the in-memory anti-abuse limiter.
|
||||||
RateLimit RateLimitConfig
|
RateLimit RateLimitConfig
|
||||||
// Abuse configures the temporary IP ban and the honeytoken (prod-only).
|
// Abuse configures the temporary IP ban and the honeytoken (prod-only).
|
||||||
@@ -226,14 +232,15 @@ func (c VKIDConfig) Enabled() bool {
|
|||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
var err error
|
var err error
|
||||||
c := Config{
|
c := Config{
|
||||||
HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr),
|
HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr),
|
||||||
LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel),
|
LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel),
|
||||||
BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL),
|
BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL),
|
||||||
BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr),
|
BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr),
|
||||||
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
|
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
|
||||||
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
|
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
|
||||||
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
|
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
|
||||||
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
|
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
|
||||||
|
MinClientVersion: os.Getenv("GATEWAY_MIN_CLIENT_VERSION"),
|
||||||
VKID: VKIDConfig{
|
VKID: VKIDConfig{
|
||||||
AppID: os.Getenv("GATEWAY_VK_ID_APP_ID"),
|
AppID: os.Getenv("GATEWAY_VK_ID_APP_ID"),
|
||||||
ClientSecret: os.Getenv("GATEWAY_VK_ID_CLIENT_SECRET"),
|
ClientSecret: os.Getenv("GATEWAY_VK_ID_CLIENT_SECRET"),
|
||||||
@@ -332,6 +339,11 @@ func (c Config) validate() error {
|
|||||||
if c.MaxBodyBytes <= 0 {
|
if c.MaxBodyBytes <= 0 {
|
||||||
return fmt.Errorf("config: GATEWAY_MAX_BODY_BYTES must be positive")
|
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) {
|
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")
|
return fmt.Errorf("config: GATEWAY_ABUSE_BAN_THRESHOLD/_WINDOW/_DURATION must be positive when GATEWAY_ABUSE_BAN_ENABLED")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
// TestLoadAbuseDefaults verifies the anti-abuse ban defaults: disabled (prod-only),
|
||||||
// the agreed thresholds, and no honeytoken.
|
// the agreed thresholds, and no honeytoken.
|
||||||
func TestLoadAbuseDefaults(t *testing.T) {
|
func TestLoadAbuseDefaults(t *testing.T) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ var (
|
|||||||
errInternal = errors.New("internal error")
|
errInternal = errors.New("internal error")
|
||||||
errMissingToken = errors.New("missing session token")
|
errMissingToken = errors.New("missing session token")
|
||||||
errInvalidSession = errors.New("invalid or expired session")
|
errInvalidSession = errors.New("invalid or expired session")
|
||||||
|
errUpdateRequired = errors.New("client too old, update required")
|
||||||
)
|
)
|
||||||
|
|
||||||
// errUnknownMessageType reports an unregistered message type.
|
// errUnknownMessageType reports an unregistered message type.
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import (
|
|||||||
"golang.org/x/net/http2/h2c"
|
"golang.org/x/net/http2/h2c"
|
||||||
|
|
||||||
"scrabble/gateway/internal/backendclient"
|
"scrabble/gateway/internal/backendclient"
|
||||||
|
"scrabble/gateway/internal/clientver"
|
||||||
"scrabble/gateway/internal/config"
|
"scrabble/gateway/internal/config"
|
||||||
"scrabble/gateway/internal/push"
|
"scrabble/gateway/internal/push"
|
||||||
"scrabble/gateway/internal/ratelimit"
|
"scrabble/gateway/internal/ratelimit"
|
||||||
@@ -46,6 +47,16 @@ const heartbeatKind = "heartbeat"
|
|||||||
// spoofer, so trusting it is safe.
|
// spoofer, so trusting it is safe.
|
||||||
const honeypotHeader = "X-Scrabble-Honeypot"
|
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
|
// Limiter classes, the `class` attribute of gateway_rate_limited_total and the
|
||||||
// class field of the periodic rejection report.
|
// class field of the periodic rejection report.
|
||||||
const (
|
const (
|
||||||
@@ -88,6 +99,11 @@ type Server struct {
|
|||||||
|
|
||||||
maxBodyBytes int
|
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
|
publicPolicy ratelimit.Policy
|
||||||
userPolicy ratelimit.Policy
|
userPolicy ratelimit.Policy
|
||||||
emailPolicy ratelimit.Policy
|
emailPolicy ratelimit.Policy
|
||||||
@@ -128,6 +144,10 @@ type Deps struct {
|
|||||||
// MaxBodyBytes caps one inbound request body and one Connect message read;
|
// MaxBodyBytes caps one inbound request body and one Connect message read;
|
||||||
// zero or negative selects config.DefaultMaxBodyBytes.
|
// zero or negative selects config.DefaultMaxBodyBytes.
|
||||||
MaxBodyBytes int
|
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.
|
// NewServer constructs the edge service.
|
||||||
@@ -160,6 +180,18 @@ func NewServer(d Deps) *Server {
|
|||||||
if rl == (config.RateLimitConfig{}) {
|
if rl == (config.RateLimitConfig{}) {
|
||||||
rl = config.DefaultRateLimit()
|
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{
|
return &Server{
|
||||||
registry: d.Registry,
|
registry: d.Registry,
|
||||||
sessions: d.Sessions,
|
sessions: d.Sessions,
|
||||||
@@ -176,6 +208,8 @@ func NewServer(d Deps) *Server {
|
|||||||
adminProxy: d.AdminProxy,
|
adminProxy: d.AdminProxy,
|
||||||
metrics: newServerMetrics(d.Meter, blocklist),
|
metrics: newServerMetrics(d.Meter, blocklist),
|
||||||
maxBodyBytes: maxBody,
|
maxBodyBytes: maxBody,
|
||||||
|
minClient: minClient,
|
||||||
|
gateOn: gateOn,
|
||||||
publicPolicy: ratelimit.PerMinute(rl.PublicPerMinute, rl.PublicBurst),
|
publicPolicy: ratelimit.PerMinute(rl.PublicPerMinute, rl.PublicBurst),
|
||||||
userPolicy: ratelimit.PerMinute(rl.UserPerMinute, rl.UserBurst),
|
userPolicy: ratelimit.PerMinute(rl.UserPerMinute, rl.UserBurst),
|
||||||
emailPolicy: ratelimit.Per(rl.EmailPer10Min, 10*time.Minute, rl.EmailBurst),
|
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
|
// Execute runs one unary operation. Domain failures are returned in the envelope
|
||||||
// (result_code != "ok", HTTP 200); only edge failures (rate limit, missing
|
// (result_code != "ok", HTTP 200); only edge failures (rate limit, missing
|
||||||
// session, unknown type, internal) become Connect errors.
|
// 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"
|
result := "internal"
|
||||||
defer func() { s.metrics.recordEdge(ctx, msgType, result, start) }()
|
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)
|
op, ok := s.registry.Lookup(msgType)
|
||||||
if !ok {
|
if !ok {
|
||||||
result = "unknown_type"
|
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
|
// Subscribe streams the authenticated user's live events with a keep-alive
|
||||||
// heartbeat until the client disconnects.
|
// heartbeat until the client disconnects.
|
||||||
func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.SubscribeRequest], stream *connect.ServerStream[edgev1.Event]) error {
|
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()))
|
uid, _, _, err := s.resolve(ctx, req.Header(), peerIP(req.Peer().Addr, req.Header()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -22,8 +22,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// newEdge wires a connectsrv.Server over a fake backend and returns a Connect
|
// 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()) {
|
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()
|
t.Helper()
|
||||||
backendSrv := httptest.NewServer(backendHandler)
|
backendSrv := httptest.NewServer(backendHandler)
|
||||||
backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second)
|
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)
|
t.Fatalf("backendclient: %v", err)
|
||||||
}
|
}
|
||||||
edge := connectsrv.NewServer(connectsrv.Deps{
|
edge := connectsrv.NewServer(connectsrv.Deps{
|
||||||
Registry: transcode.NewRegistry(backend, nil),
|
Registry: transcode.NewRegistry(backend, nil),
|
||||||
Sessions: session.NewCache(backend, time.Minute, 100),
|
Sessions: session.NewCache(backend, time.Minute, 100),
|
||||||
Limiter: ratelimit.New(),
|
Limiter: ratelimit.New(),
|
||||||
Hub: push.NewHub(0),
|
Hub: push.NewHub(0),
|
||||||
RateLimit: config.DefaultRateLimit(),
|
RateLimit: config.DefaultRateLimit(),
|
||||||
Heartbeat: 15 * time.Second,
|
Heartbeat: 15 * time.Second,
|
||||||
|
MinClientVersion: minVersion,
|
||||||
})
|
})
|
||||||
edgeSrv := httptest.NewServer(edge.HTTPHandler())
|
edgeSrv := httptest.NewServer(edge.HTTPHandler())
|
||||||
client := edgev1connect.NewGatewayClient(http.DefaultClient, edgeSrv.URL)
|
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) {
|
func TestExecuteAuthedRequiresSession(t *testing.T) {
|
||||||
client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {
|
client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
t.Error("backend must not be called without a session")
|
t.Error("backend must not be called without a session")
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte';
|
import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte';
|
||||||
import Coachmark from './components/Coachmark.svelte';
|
import Coachmark from './components/Coachmark.svelte';
|
||||||
import MaintenanceOverlay from './components/MaintenanceOverlay.svelte';
|
import MaintenanceOverlay from './components/MaintenanceOverlay.svelte';
|
||||||
|
import UpdateOverlay from './components/UpdateOverlay.svelte';
|
||||||
import DebugPanel from './components/DebugPanel.svelte';
|
import DebugPanel from './components/DebugPanel.svelte';
|
||||||
import Login from './screens/Login.svelte';
|
import Login from './screens/Login.svelte';
|
||||||
import Lobby from './screens/Lobby.svelte';
|
import Lobby from './screens/Lobby.svelte';
|
||||||
@@ -142,6 +143,7 @@
|
|||||||
<WelcomeRedeemModal />
|
<WelcomeRedeemModal />
|
||||||
<Coachmark />
|
<Coachmark />
|
||||||
<MaintenanceOverlay />
|
<MaintenanceOverlay />
|
||||||
|
<UpdateOverlay />
|
||||||
|
|
||||||
<!-- Cold-start "no connection" dialog: the reachability check timed out with the network interface
|
<!-- 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
|
reportedly online, so it is ambiguous. The player chooses to go offline (play local vs_ai) or to
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -8,6 +8,7 @@ import { createTransport } from './transport';
|
|||||||
import { setForcedSeed } from './localgame/id';
|
import { setForcedSeed } from './localgame/id';
|
||||||
import { reportOffline, reportOnline } from './connection.svelte';
|
import { reportOffline, reportOnline } from './connection.svelte';
|
||||||
import { clearMaintenance, maintenanceRecovered, reportMaintenance } from './maintenance.svelte';
|
import { clearMaintenance, maintenanceRecovered, reportMaintenance } from './maintenance.svelte';
|
||||||
|
import { reportUpdateRequired } from './update.svelte';
|
||||||
|
|
||||||
const isMock = import.meta.env.MODE === 'mock';
|
const isMock = import.meta.env.MODE === 'mock';
|
||||||
|
|
||||||
@@ -31,6 +32,9 @@ if (isMock && typeof window !== 'undefined') {
|
|||||||
off: clearMaintenance,
|
off: clearMaintenance,
|
||||||
recover: maintenanceRecovered,
|
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
|
// Drive the auto-match opponent join deterministically from the e2e (the mock otherwise
|
||||||
// attaches a robot on a timer).
|
// attaches a robot on a timer).
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ export const en = {
|
|||||||
'maintenance.title': 'Under maintenance',
|
'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.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',
|
'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.title': 'Account blocked',
|
||||||
'blocked.permanent': 'Your account is blocked.',
|
'blocked.permanent': 'Your account is blocked.',
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'maintenance.title': 'Технические работы',
|
'maintenance.title': 'Технические работы',
|
||||||
'maintenance.body': 'Идёт короткое обновление игры. Мы скоро вернёмся — страницу перезагружать не нужно.',
|
'maintenance.body': 'Идёт короткое обновление игры. Мы скоро вернёмся — страницу перезагружать не нужно.',
|
||||||
'maintenance.retry': 'Повторить',
|
'maintenance.retry': 'Повторить',
|
||||||
|
'update.title': 'Требуется обновление',
|
||||||
|
'update.body': 'Приложение устарело и не может продолжить работу. Загрузите обновлённую версию, чтобы продолжить играть и побеждать.',
|
||||||
|
'update.action': 'Обновить',
|
||||||
|
|
||||||
'blocked.title': 'Учётная запись заблокирована',
|
'blocked.title': 'Учётная запись заблокирована',
|
||||||
'blocked.permanent': 'Ваша учётная запись заблокирована.',
|
'blocked.permanent': 'Ваша учётная запись заблокирована.',
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ describe('toGatewayError', () => {
|
|||||||
expect(toGatewayError(new ConnectError('x', Code.Internal)).code).toBe('internal');
|
expect(toGatewayError(new ConnectError('x', Code.Internal)).code).toBe('internal');
|
||||||
expect(isConnectionCode('internal')).toBe(false);
|
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', () => {
|
describe('retryable', () => {
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ export function toGatewayError(e: unknown): GatewayError {
|
|||||||
return new GatewayError('unavailable', e.message);
|
return new GatewayError('unavailable', e.message);
|
||||||
case Code.NotFound:
|
case Code.NotFound:
|
||||||
return new GatewayError('not_found', e.message);
|
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:
|
default:
|
||||||
return new GatewayError('internal', e.message);
|
return new GatewayError('internal', e.message);
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-4
@@ -17,6 +17,7 @@ import { offlineMode } from './offline.svelte';
|
|||||||
import { maintenanceRecovered, registerMaintenanceProbe, reportMaintenance } from './maintenance.svelte';
|
import { maintenanceRecovered, registerMaintenanceProbe, reportMaintenance } from './maintenance.svelte';
|
||||||
import { maintenanceRetryMs } from './maintenance';
|
import { maintenanceRetryMs } from './maintenance';
|
||||||
import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry';
|
import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry';
|
||||||
|
import { UPDATE_REQUIRED, reportUpdateRequired } from './update.svelte';
|
||||||
|
|
||||||
const MAX_RETRIES = 6;
|
const MAX_RETRIES = 6;
|
||||||
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
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);
|
const client = createClient(Gateway, transport);
|
||||||
let token: string | null = null;
|
let token: string | null = null;
|
||||||
|
|
||||||
const headers = (): Record<string, string> | undefined =>
|
// Every call carries the build version so the edge's client-version gate can turn away a build
|
||||||
token ? { authorization: `Bearer ${token}` } : undefined;
|
// 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
|
// 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).
|
// (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);
|
throw toGatewayError(e);
|
||||||
}
|
}
|
||||||
const err = 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) {
|
if (retryable(err.code, messageType) && attempt < MAX_RETRIES) {
|
||||||
reportOffline();
|
reportOffline();
|
||||||
await sleep(backoffMs(attempt + 1));
|
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 —
|
// 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).
|
// reload to pick up the (possibly incompatible) fresh client (maintenance.svelte.ts).
|
||||||
maintenanceRecovered();
|
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;
|
return res.payload;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -363,7 +375,9 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
if (!ctrl.signal.aborted) {
|
if (!ctrl.signal.aborted) {
|
||||||
const maintMs = maintenanceRetryMs(e);
|
const maintMs = maintenanceRetryMs(e);
|
||||||
if (maintMs !== null) reportMaintenance(maintMs);
|
if (maintMs !== null) reportMaintenance(maintMs);
|
||||||
onError?.(toGatewayError(e));
|
const err = toGatewayError(e);
|
||||||
|
if (err.code === UPDATE_REQUIRED) reportUpdateRequired();
|
||||||
|
onError?.(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user