feat(offline): implicit net-state model, two-tier version gate, unified lobby #249
+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
|
||||||
|
|||||||
@@ -237,6 +237,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
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).
|
||||||
@@ -234,6 +240,7 @@ func Load() (Config, error) {
|
|||||||
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)
|
||||||
@@ -37,6 +42,7 @@ func newEdge(t *testing.T, backendHandler http.HandlerFunc) (edgev1connect.Gatew
|
|||||||
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