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:
Ilia Denisov
2026-07-12 15:47:41 +02:00
parent 2ea91a8354
commit a57fd355ba
19 changed files with 558 additions and 47 deletions
+95
View File
@@ -0,0 +1,95 @@
<script lang="ts">
// Non-dismissable "update required" cover. Shown when the gateway has refused a foreground call
// as too old (update.svelte.ts — the client-version gate). Unlike the maintenance overlay this is
// terminal: an installed build cannot become compatible without an actual update, so its one
// action takes the user to the fix — the store listing on a native build (VITE_STORE_URL, opened
// in the system browser / store app) or a plain reload on the web (which fetches the current
// client). Mirrors MaintenanceOverlay.svelte's look.
import { updateRequired } from '../lib/update.svelte';
import { clientChannel } from '../lib/channel';
import { t } from '../lib/i18n/index.svelte';
function onAction(): void {
const ch = clientChannel();
if (ch === 'android' || ch === 'ios') {
// '_system' hands the URL to the OS (the store app / external browser) rather than the WebView.
const url = import.meta.env.VITE_STORE_URL;
if (url) window.open(url, '_system');
} else {
location.reload();
}
}
</script>
{#if updateRequired.active}
<div class="scrim" role="alertdialog" aria-modal="true" aria-labelledby="update-title" aria-describedby="update-body">
<div class="card">
<div class="tile" aria-hidden="true">Э</div>
<h1 id="update-title">{t('update.title')}</h1>
<p id="update-body">{t('update.body')}</p>
<button type="button" onclick={onAction}>{t('update.action')}</button>
</div>
</div>
{/if}
<style>
.scrim {
position: fixed;
inset: 0;
/* Above the game (z 60) and toasts (z 50) — a terminal update block covers everything the user
could otherwise interact with; below the dev DebugPanel (z 10000). */
z-index: 100;
background: rgba(0, 0, 0, 0.55);
display: grid;
place-items: center;
padding: 24px;
box-sizing: border-box;
}
.card {
max-width: 22rem;
text-align: center;
background: var(--surface);
color: var(--text);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 2rem 1.5rem;
}
/* Mirrors a placed board tile (as Splash.svelte / MaintenanceOverlay.svelte do). */
.tile {
display: inline-grid;
place-items: center;
width: 3.5rem;
height: 3.5rem;
font-size: 2rem;
font-weight: 700;
border-radius: 4px;
background: var(--tile-bg);
color: var(--tile-text);
box-shadow:
inset 0 -2px 0 var(--tile-edge),
2px 0 3px -1px rgba(0, 0, 0, 0.4);
margin-bottom: 1.25rem;
}
h1 {
font-size: 1.25rem;
margin: 0 0 0.5rem;
}
p {
margin: 0 0 1.5rem;
color: var(--text-muted);
line-height: 1.5;
}
button {
font: inherit;
padding: 0.55rem 1.4rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: transparent;
color: var(--text);
cursor: pointer;
}
button:hover {
border-color: var(--accent);
color: var(--accent);
}
</style>