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:
@@ -11,6 +11,7 @@
|
||||
import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte';
|
||||
import Coachmark from './components/Coachmark.svelte';
|
||||
import MaintenanceOverlay from './components/MaintenanceOverlay.svelte';
|
||||
import UpdateOverlay from './components/UpdateOverlay.svelte';
|
||||
import DebugPanel from './components/DebugPanel.svelte';
|
||||
import Login from './screens/Login.svelte';
|
||||
import Lobby from './screens/Lobby.svelte';
|
||||
@@ -142,6 +143,7 @@
|
||||
<WelcomeRedeemModal />
|
||||
<Coachmark />
|
||||
<MaintenanceOverlay />
|
||||
<UpdateOverlay />
|
||||
|
||||
<!-- Cold-start "no connection" dialog: the reachability check timed out with the network interface
|
||||
reportedly online, so it is ambiguous. The player chooses to go offline (play local vs_ai) or to
|
||||
|
||||
@@ -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 { reportOffline, reportOnline } from './connection.svelte';
|
||||
import { clearMaintenance, maintenanceRecovered, reportMaintenance } from './maintenance.svelte';
|
||||
import { reportUpdateRequired } from './update.svelte';
|
||||
|
||||
const isMock = import.meta.env.MODE === 'mock';
|
||||
|
||||
@@ -31,6 +32,9 @@ if (isMock && typeof window !== 'undefined') {
|
||||
off: clearMaintenance,
|
||||
recover: maintenanceRecovered,
|
||||
};
|
||||
// The mock never receives a real update_required from the edge, so the e2e drives the terminal
|
||||
// "update required" overlay directly (it is terminal — there is no clear hook).
|
||||
(window as unknown as { __update?: { on(): void } }).__update = { on: reportUpdateRequired };
|
||||
// Drive the auto-match opponent join deterministically from the e2e (the mock otherwise
|
||||
// attaches a robot on a timer).
|
||||
(
|
||||
|
||||
@@ -10,6 +10,9 @@ export const en = {
|
||||
'maintenance.title': 'Under maintenance',
|
||||
'maintenance.body': 'Scrabble is briefly down for an update. It will be back in a moment — no need to reload the page.',
|
||||
'maintenance.retry': 'Try again',
|
||||
'update.title': 'Update required',
|
||||
'update.body': 'This app is out of date and can no longer run. Download the update to keep playing — and winning.',
|
||||
'update.action': 'Update',
|
||||
|
||||
'blocked.title': 'Account blocked',
|
||||
'blocked.permanent': 'Your account is blocked.',
|
||||
|
||||
@@ -11,6 +11,9 @@ export const ru: Record<MessageKey, string> = {
|
||||
'maintenance.title': 'Технические работы',
|
||||
'maintenance.body': 'Идёт короткое обновление игры. Мы скоро вернёмся — страницу перезагружать не нужно.',
|
||||
'maintenance.retry': 'Повторить',
|
||||
'update.title': 'Требуется обновление',
|
||||
'update.body': 'Приложение устарело и не может продолжить работу. Загрузите обновлённую версию, чтобы продолжить играть и побеждать.',
|
||||
'update.action': 'Обновить',
|
||||
|
||||
'blocked.title': 'Учётная запись заблокирована',
|
||||
'blocked.permanent': 'Ваша учётная запись заблокирована.',
|
||||
|
||||
@@ -21,6 +21,10 @@ describe('toGatewayError', () => {
|
||||
expect(toGatewayError(new ConnectError('x', Code.Internal)).code).toBe('internal');
|
||||
expect(isConnectionCode('internal')).toBe(false);
|
||||
});
|
||||
|
||||
it('maps the Subscribe FailedPrecondition to the update_required sentinel (client too old)', () => {
|
||||
expect(toGatewayError(new ConnectError('x', Code.FailedPrecondition)).code).toBe('update_required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('retryable', () => {
|
||||
|
||||
@@ -33,6 +33,9 @@ export function toGatewayError(e: unknown): GatewayError {
|
||||
return new GatewayError('unavailable', e.message);
|
||||
case Code.NotFound:
|
||||
return new GatewayError('not_found', e.message);
|
||||
case Code.FailedPrecondition:
|
||||
// The Subscribe stream's counterpart of the update_required envelope: the client is too old.
|
||||
return new GatewayError('update_required', e.message);
|
||||
default:
|
||||
return new GatewayError('internal', e.message);
|
||||
}
|
||||
|
||||
+18
-4
@@ -17,6 +17,7 @@ import { offlineMode } from './offline.svelte';
|
||||
import { maintenanceRecovered, registerMaintenanceProbe, reportMaintenance } from './maintenance.svelte';
|
||||
import { maintenanceRetryMs } from './maintenance';
|
||||
import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry';
|
||||
import { UPDATE_REQUIRED, reportUpdateRequired } from './update.svelte';
|
||||
|
||||
const MAX_RETRIES = 6;
|
||||
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
||||
@@ -34,8 +35,14 @@ export function createTransport(baseUrl: string): GatewayClient {
|
||||
const client = createClient(Gateway, transport);
|
||||
let token: string | null = null;
|
||||
|
||||
const headers = (): Record<string, string> | undefined =>
|
||||
token ? { authorization: `Bearer ${token}` } : undefined;
|
||||
// Every call carries the build version so the edge's client-version gate can turn away a build
|
||||
// too old to speak the current wire contract, before it decodes the payload (the header rides the
|
||||
// outermost stable layer; see docs/ARCHITECTURE.md §2).
|
||||
const headers = (): Record<string, string> => {
|
||||
const h: Record<string, string> = { 'x-client-version': __APP_VERSION__ };
|
||||
if (token) h.authorization = `Bearer ${token}`;
|
||||
return h;
|
||||
};
|
||||
|
||||
// The reachability probe the connection watcher fires while offline: a cheap authenticated read
|
||||
// (it must reject when there is no session, so the watcher keeps waiting rather than reporting up).
|
||||
@@ -71,6 +78,8 @@ export function createTransport(baseUrl: string): GatewayClient {
|
||||
throw toGatewayError(e);
|
||||
}
|
||||
const err = toGatewayError(e);
|
||||
// A too-old client turned away on a foreground call raises the terminal update overlay.
|
||||
if (err.code === UPDATE_REQUIRED) reportUpdateRequired();
|
||||
if (retryable(err.code, messageType) && attempt < MAX_RETRIES) {
|
||||
reportOffline();
|
||||
await sleep(backoffMs(attempt + 1));
|
||||
@@ -83,7 +92,10 @@ export function createTransport(baseUrl: string): GatewayClient {
|
||||
// A read got through: if the maintenance overlay was up, the deploy window has ended —
|
||||
// reload to pick up the (possibly incompatible) fresh client (maintenance.svelte.ts).
|
||||
maintenanceRecovered();
|
||||
if (res.resultCode && res.resultCode !== 'ok') throw new GatewayError(res.resultCode);
|
||||
if (res.resultCode && res.resultCode !== 'ok') {
|
||||
if (res.resultCode === UPDATE_REQUIRED) reportUpdateRequired();
|
||||
throw new GatewayError(res.resultCode);
|
||||
}
|
||||
return res.payload;
|
||||
}
|
||||
}
|
||||
@@ -363,7 +375,9 @@ export function createTransport(baseUrl: string): GatewayClient {
|
||||
if (!ctrl.signal.aborted) {
|
||||
const maintMs = maintenanceRetryMs(e);
|
||||
if (maintMs !== null) reportMaintenance(maintMs);
|
||||
onError?.(toGatewayError(e));
|
||||
const err = toGatewayError(e);
|
||||
if (err.code === UPDATE_REQUIRED) reportUpdateRequired();
|
||||
onError?.(err);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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