Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a8f78c7880 |
@@ -353,11 +353,6 @@ jobs:
|
||||
# for the server-side confidential code exchange — a SEPARATE VK app from the Mini
|
||||
# App above. One VK ID "Web" app serves every contour -> unprefixed secret.
|
||||
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
||||
# Client-version gate (ARCHITECTURE.md §2): one plain (unprefixed) variable serves every
|
||||
# contour. In the test contour the stamped client version is a commit hash (unparseable ⇒
|
||||
# fail-open), so setting these only enforces on real semver prod builds. Empty ⇒ dormant.
|
||||
GATEWAY_MIN_CLIENT_VERSION: ${{ vars.GATEWAY_MIN_CLIENT_VERSION }}
|
||||
GATEWAY_RECOMMENDED_CLIENT_VERSION: ${{ vars.GATEWAY_RECOMMENDED_CLIENT_VERSION }}
|
||||
# Planted honeytoken bearer: presenting it flags the caller (logs + a ban metric on
|
||||
# test where the IP ban is off; a 24h IP ban on prod). Per-contour secret; empty = trap off.
|
||||
GATEWAY_HONEYTOKEN: ${{ secrets.TEST_GATEWAY_HONEYTOKEN }}
|
||||
|
||||
@@ -112,12 +112,6 @@ jobs:
|
||||
# derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
|
||||
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
|
||||
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
||||
# Client-version gate (ARCHITECTURE.md §2): plain (unprefixed) vars, shared across contours
|
||||
# (empty ⇒ dormant). On prod the stamped client version is a real semver, so the gate enforces
|
||||
# here — set GATEWAY_MIN_CLIENT_VERSION to the release in the same rollout that ships a breaking
|
||||
# wire change; bump GATEWAY_RECOMMENDED_CLIENT_VERSION (≥ min) to nudge upgrades softly.
|
||||
GATEWAY_MIN_CLIENT_VERSION: ${{ vars.GATEWAY_MIN_CLIENT_VERSION }}
|
||||
GATEWAY_RECOMMENDED_CLIENT_VERSION: ${{ vars.GATEWAY_RECOMMENDED_CLIENT_VERSION }}
|
||||
# Planted honeytoken bearer: presenting it earns a 24h IP ban + a high-severity alarm.
|
||||
# Per-contour secret; empty = trap off. Rendered by deploy/write-prod-env.sh.
|
||||
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
|
||||
|
||||
@@ -115,13 +115,6 @@ TELEGRAM_MINIAPP_URL= # required; deploy derives it as PUBLIC_B
|
||||
TELEGRAM_TEST_ENV=false
|
||||
TELEGRAM_API_BASE_URL=
|
||||
|
||||
# --- Client-version gate (ARCHITECTURE.md §2) -------------------------------
|
||||
# The hard minimum + the soft recommended client build. Empty ⇒ dormant. Plain (unprefixed) Gitea
|
||||
# variables, shared across contours; in the test contour the stamped client version is a commit hash
|
||||
# (unparseable ⇒ fail-open), so these only enforce on real semver prod builds. Recommended must be ≥ min.
|
||||
GATEWAY_MIN_CLIENT_VERSION=
|
||||
GATEWAY_RECOMMENDED_CLIENT_VERSION=
|
||||
|
||||
# --- VK Mini App ------------------------------------------------------------
|
||||
# The VK app's "protected key" (client_secret): the gateway verifies the Mini App
|
||||
# launch-parameter signature in-process under it (a pure offline HMAC, no VK API call).
|
||||
|
||||
@@ -222,12 +222,6 @@ services:
|
||||
GATEWAY_HTTP_ADDR: ":8081"
|
||||
GATEWAY_BACKEND_HTTP_URL: http://backend:8080
|
||||
GATEWAY_BACKEND_GRPC_ADDR: backend:9090
|
||||
# Client-version gate (ARCHITECTURE.md §2): the hard minimum + the soft recommended client build.
|
||||
# Empty ⇒ dormant. Plain (unprefixed) — the same value serves every contour; in the test contour
|
||||
# the stamped client version is a commit hash (unparseable ⇒ fail-open), so the gate only bites
|
||||
# real semver builds in prod. Validated at gateway start (recommended must be ≥ min).
|
||||
GATEWAY_MIN_CLIENT_VERSION: ${GATEWAY_MIN_CLIENT_VERSION:-}
|
||||
GATEWAY_RECOMMENDED_CLIENT_VERSION: ${GATEWAY_RECOMMENDED_CLIENT_VERSION:-}
|
||||
# Telegram auth validates against the home validator (plaintext, internal).
|
||||
GATEWAY_VALIDATOR_ADDR: validator:9091
|
||||
# VK Mini App auth verifies the launch-parameter signature in-process under the VK
|
||||
|
||||
@@ -49,8 +49,6 @@ export CADDY_SITE_ADDRESS='$CADDY_SITE_ADDRESS'
|
||||
export LOG_LEVEL='${LOG_LEVEL:-info}'
|
||||
export DICT_VERSION='$DICT_VERSION'
|
||||
export APP_VERSION='$APP_VERSION'
|
||||
export GATEWAY_MIN_CLIENT_VERSION='$GATEWAY_MIN_CLIENT_VERSION'
|
||||
export GATEWAY_RECOMMENDED_CLIENT_VERSION='$GATEWAY_RECOMMENDED_CLIENT_VERSION'
|
||||
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
||||
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
||||
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
|
||||
|
||||
@@ -116,10 +116,8 @@ dropped). Horizontal scaling is explicit future work.
|
||||
one whose response was lost — its button is disabled while offline and re-issued on reconnect). A
|
||||
reachability watcher (a lightweight `profile.get` probe; a session-less native guest reconciles a
|
||||
server guest instead — that reconcile IS the probe) drives recovery, and the live `Subscribe` stream's
|
||||
drop/recovery feeds the same machine. **Telegram/VK are exempt** — always online: they are never fed an
|
||||
offline signal, and `offlineMode.active` is additionally hard-gated on `offlineCapable()` (false in
|
||||
those mini-apps), so nothing — not even a version lock — puts them in offline mode: no blue chrome, no
|
||||
local lobby, no transport kill switch and no device-local create paths.
|
||||
drop/recovery feeds the same machine. **Telegram/VK are exempt** — always online, never fed an offline
|
||||
signal, so those channels never enter an offline state or show a local lobby.
|
||||
**Edge hardening:** every request body on the public listener is capped at
|
||||
`GATEWAY_MAX_BODY_BYTES` (default 1 MiB — far above any legitimate payload), both at the HTTP
|
||||
layer (`http.MaxBytesReader`) and as the Connect per-message read limit, so an oversized
|
||||
@@ -1395,11 +1393,9 @@ for install on Android) and powers **offline play**. Offline is **implicit** —
|
||||
(the old deliberate Settings switch and its cold-start dialog are gone). The **unified lobby** merges the
|
||||
device-local games (active — reconstructed by replaying the IndexedDB move journal) with the last-cached
|
||||
**server games shown greyed** (un-openable, a tap toasts *offline*); the Stats tab is disabled and
|
||||
invitations are hidden while offline. On offline-capable channels (native / plain web) New Game's *with
|
||||
friends* carries an **online/offline segmented control** — online = a friend invite, offline = **local
|
||||
pass-and-play (hotseat)** — with the online segment disabled and offline forced when there is no network;
|
||||
the online-only Telegram/VK mini-apps skip the segment and show the remote invite alone. A `vs_ai` or
|
||||
hotseat create is guarded when
|
||||
invitations are hidden while offline. New Game's *with friends* carries an **online/offline segmented
|
||||
control** — online = a friend invite, offline = **local pass-and-play (hotseat)** — with the online
|
||||
segment disabled and offline forced when there is no network; a `vs_ai` or hotseat create is guarded when
|
||||
the chosen variant's dictionary is not available offline. A device-local `vs_ai` game is created through
|
||||
the in-browser engine and driven by the same game screen, the robot replying locally.
|
||||
A hotseat game is a device-local **2-4 human** game built through the same engine (`hotseat` +
|
||||
|
||||
+1
-4
@@ -277,9 +277,7 @@ add-friend are off. AI games are **practice** — they never count toward a play
|
||||
statistics.
|
||||
|
||||
### Offline mode
|
||||
The **web and native apps** play **offline automatically** — there is **no on/off switch**; the
|
||||
online-only **Telegram and VK mini-apps** never go offline (everything below applies to the web and
|
||||
native apps only). When it cannot reach the
|
||||
The app plays **offline automatically** — there is **no on/off switch**. When it cannot reach the
|
||||
server the header turns blue with an *Offline* chip and play is confined to games on the device; a
|
||||
momentary blip is ridden out as a quiet *"Connecting…"* (it never drops you offline), and when the
|
||||
connection returns the app goes back online on its own with a brief *back online* toast. Offline, the
|
||||
@@ -297,7 +295,6 @@ a short note.
|
||||
|
||||
New Game's **with friends** offers a choice — **invite a friend** to play online, or a **local
|
||||
pass-and-play** game for 2-4 people sharing one device; with no network only pass-and-play is available.
|
||||
(In the online-only Telegram/VK mini-apps *with friends* is the friend invite alone — no pass-and-play.)
|
||||
In pass-and-play you first set a **host (leader) password** — a 4-digit PIN entered on a lock-screen-style
|
||||
keypad; until it is set the player rows stay locked. The app then asks whether you are playing too —
|
||||
if so you take the first seat with your profile name. You name each player (2 to 4; add or remove
|
||||
|
||||
@@ -283,9 +283,7 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
||||
редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
|
||||
|
||||
### Офлайн-режим
|
||||
**Веб- и нативное приложения** играют **офлайн автоматически** — никакого переключателя нет; онлайн-только
|
||||
**мини-приложения Telegram и VK** никогда не уходят в офлайн (всё ниже относится только к веб- и нативному
|
||||
приложениям). Когда оно не может достучаться
|
||||
Приложение играет **офлайн автоматически** — никакого переключателя нет. Когда оно не может достучаться
|
||||
до сервера, шапка синеет с меткой *Офлайн*, а игра ограничивается партиями на устройстве; кратковременный
|
||||
сбой пережидается как тихое *«Подключение…»* (в офлайн не роняет), а при возврате связи приложение само
|
||||
возвращается онлайн с коротким тостом *соединение восстановлено*. В офлайне приложение не обращается к
|
||||
@@ -301,9 +299,8 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
||||
варианта недоступен офлайн, создание такой игры отключается с короткой заметкой.
|
||||
|
||||
«С друзьями» в Новой игре предлагает выбор — **пригласить друга** для игры онлайн или **локальную игру
|
||||
по очереди (hotseat)** на 2–4 человек за одним устройством; без сети доступна только игра по очереди.
|
||||
(В онлайн-только мини-приложениях Telegram/VK «с друзьями» — это только приглашение друга, без игры по очереди.)
|
||||
В игре по очереди сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
|
||||
по очереди (hotseat)** на 2–4 человек за одним устройством; без сети доступна только игра по очереди. В
|
||||
игре по очереди сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
|
||||
экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает,
|
||||
играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого
|
||||
игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете
|
||||
|
||||
+3
-8
@@ -42,15 +42,10 @@ ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
|
||||
VITE_APP_VERSION=$VITE_APP_VERSION \
|
||||
VITE_ADS_STUB=$VITE_ADS_STUB
|
||||
|
||||
# Install with the lockfile first, then build. Committed src/gen/ means no codegen here.
|
||||
# --ignore-scripts: the Vite build needs no dependency build script — esbuild's platform binary
|
||||
# ships as an optional dependency (only its CLI-shim postinstall is skipped, which Vite does not use),
|
||||
# and core-js-bundle's prebuilt file is read directly. It notably skips sharp's native build (a
|
||||
# `pnpm android:assets` tool via @capacitor/assets, unused here), which would otherwise fail on this
|
||||
# Alpine/musl stage with no prebuilt binary and no compiler. The workspace allowBuilds stay for local
|
||||
# installs (where android:assets does need sharp built).
|
||||
# Install with the lockfile first (the workspace file carries pnpm's build-script
|
||||
# approval for esbuild), then build. Committed src/gen/ means no codegen here.
|
||||
COPY ui/package.json ui/pnpm-lock.yaml ui/pnpm-workspace.yaml ./
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY ui ./
|
||||
RUN pnpm build
|
||||
|
||||
|
||||
@@ -122,54 +122,6 @@ test('outside Telegram, the /telegram/ entry shows the launch diagnostic, not a
|
||||
await expect(page.getByRole('button', { name: /guest/i })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('inside Telegram (online-only), an offline signal never switches the app to offline mode', async ({
|
||||
page,
|
||||
}) => {
|
||||
// The Telegram mini-app is online-only: the offline model (implicit offline, device-local games,
|
||||
// the transport kill switch) must stay inert there. Even when the net-state machine is driven to an
|
||||
// offline state, offlineMode must remain false — otherwise the chrome turns blue, the lobby greys the
|
||||
// server games and, critically, a vs_ai start would create a device-local game instead of enqueuing.
|
||||
await page.addInitScript((stub) => {
|
||||
Object.assign(window, stub);
|
||||
}, webAppStub());
|
||||
await page.goto('/');
|
||||
await expect(page.getByText('Your turn')).toBeVisible(); // the durable mini-app session, in the lobby
|
||||
|
||||
// Drive the net-state machine to an offline state through the mock hook.
|
||||
await page.evaluate(() => (window as unknown as { __net: { offline(): void } }).__net.offline());
|
||||
|
||||
// Because the channel is online-only, offlineMode stays inert: on the lobby the chrome never turns blue
|
||||
// and no server game is greyed…
|
||||
await expect(page.locator('header.nav.offline')).toHaveCount(0);
|
||||
await expect(page.locator('.rowwrap.greyed')).toHaveCount(0);
|
||||
// …and New Game's quick match still offers the opponent choice (AI / random), which is hidden only in
|
||||
// real offline mode. Its presence is the positive proof that offlineMode is false — so the vs_ai start,
|
||||
// whose device-local branch is gated on offlineMode, enqueues on the server instead of creating a
|
||||
// device-local game.
|
||||
await page.locator('button.tab').nth(0).click();
|
||||
await expect(page.locator('button.opt', { hasText: '🤖' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('inside Telegram (online-only), New Game with friends offers remote invites only', async ({
|
||||
page,
|
||||
}) => {
|
||||
// A Telegram launch authenticates a durable account, so New Game shows the auto/with-friends selector.
|
||||
// "Play with friends" must offer only the remote invite — never the online/offline segment, whose
|
||||
// "Pass and play" is the device-local hotseat flow that has no place in an online-only mini-app.
|
||||
await page.addInitScript((stub) => {
|
||||
Object.assign(window, stub);
|
||||
}, webAppStub());
|
||||
await page.goto('/');
|
||||
await expect(page.getByText('Your turn')).toBeVisible();
|
||||
|
||||
await page.locator('button.tab').nth(0).click(); // the New tab opens the create screen
|
||||
await page.getByRole('button', { name: 'Play with friends' }).click();
|
||||
|
||||
// The online/offline segment (Invite a friend / Pass and play) is absent — only the invite form shows.
|
||||
await expect(page.getByRole('button', { name: 'Pass and play' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: 'Invite a friend' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('a blocked telegram-web-app.js does not hang the diagnostic screen', async ({ page }) => {
|
||||
// Simulate a network where telegram.org is unreachable: the SDK fetch fails. Because the SPA
|
||||
// loads the SDK dynamically with a timeout (not a render-blocking <script>), a failed/blocked
|
||||
|
||||
@@ -10,24 +10,11 @@ import { insideTelegram } from './telegram';
|
||||
import { insideVK } from './vk';
|
||||
import type { Profile } from './model';
|
||||
|
||||
/**
|
||||
* offlineCapable reports whether this channel supports the offline model — implicit offline mode,
|
||||
* device-local games (vs_ai and hotseat) and the transport kill switch. Native builds and the plain web
|
||||
* app do; the Telegram and VK mini-apps are online-only wrappers, so the whole offline model stays inert
|
||||
* there (offlineMode.active is forced false regardless of the detected net state, and the create flow
|
||||
* hides its device-local segment).
|
||||
*/
|
||||
export function offlineCapable(): boolean {
|
||||
return !insideTelegram() && !insideVK();
|
||||
}
|
||||
|
||||
/** offlineMode exposes the reactive offline flags; read them in markup / $derived. */
|
||||
export const offlineMode = {
|
||||
/** active is true while the app is offline (the machine's offlineNoNetwork / offlineVersionLocked) on a
|
||||
* channel that supports offline play; it is always false in the online-only Telegram/VK mini-apps, so
|
||||
* every offline-model consumer (chrome, lobby, kill switch, device-local create) stays inert there. */
|
||||
/** active is true while the app is offline (the machine's offlineNoNetwork / offlineVersionLocked). */
|
||||
get active(): boolean {
|
||||
return netState.offline && offlineCapable();
|
||||
return netState.offline;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { gateway } from '../lib/gateway';
|
||||
import { app, handleError, showToast } from '../lib/app.svelte';
|
||||
import { connection } from '../lib/connection.svelte';
|
||||
import { offlineMode, offlineCapable } from '../lib/offline.svelte';
|
||||
import { offlineMode } from '../lib/offline.svelte';
|
||||
import { localSource, isLocalGameId } from '../lib/gamesource';
|
||||
import { newLocalGameId, randomSeed } from '../lib/localgame/id';
|
||||
import { localGuestId } from '../lib/localguest';
|
||||
@@ -51,14 +51,10 @@
|
||||
];
|
||||
|
||||
const guest = $derived(app.profile?.isGuest ?? true);
|
||||
// Whether this channel supports device-local play. Native and plain web do; the Telegram/VK mini-apps
|
||||
// are online-only, so they offer no offline/hotseat segment and never create a device-local game. The
|
||||
// channel is fixed for the session, so a plain const (not reactive) is enough.
|
||||
const canPlayOffline = offlineCapable();
|
||||
let mode = $state<'auto' | 'friends'>('auto');
|
||||
// Within "with friends" (offline-capable channels only): online = a remote invite, offline = a
|
||||
// pass-and-play (hotseat) game on this device. With no network the online segment is disabled and
|
||||
// offline is forced — so the create flow always works.
|
||||
// Within "with friends": online = a remote invite, offline = a pass-and-play (hotseat) game on this
|
||||
// device. Both exist regardless of connectivity, but with no network the online segment is disabled
|
||||
// and offline is forced — so the create flow always works.
|
||||
let friendsMode = $state<'online' | 'offline'>('online');
|
||||
$effect(() => {
|
||||
if (offlineMode.active) friendsMode = 'offline';
|
||||
@@ -414,17 +410,14 @@
|
||||
onlogin={() => { limitOpen = false; navigate('/profile'); }}
|
||||
/>
|
||||
{:else}
|
||||
<!-- With friends: an online remote invite, or an offline pass-and-play (hotseat) on this device.
|
||||
The online/offline segment is offered only on offline-capable channels (native / plain web);
|
||||
the online-only Telegram/VK mini-apps skip it and show the remote invite directly. With no
|
||||
network the online segment is disabled and offline is forced (friendsMode). -->
|
||||
{#if canPlayOffline}
|
||||
<!-- With friends: an online remote invite, or an offline pass-and-play (hotseat) on this
|
||||
device. Both exist regardless of connectivity, but with no network the online segment is
|
||||
disabled and offline is forced (friendsMode). -->
|
||||
<div class="seg modes">
|
||||
<button class="opt" class:active={friendsMode === 'online'} disabled={offlineMode.active} onclick={() => (friendsMode = 'online')}>{t('new.playRemote')}</button>
|
||||
<button class="opt" class:active={friendsMode === 'offline'} onclick={() => (friendsMode = 'offline')}>{t('new.playLocal')}</button>
|
||||
</div>
|
||||
{#if offlineMode.active}<p class="dictnote">{t('new.needsNetwork')}</p>{/if}
|
||||
{/if}
|
||||
{#if friendsMode === 'offline'}
|
||||
<!-- Offline pass-and-play (hotseat): a device-local 2-4 human game. The host sets a master
|
||||
PIN first (it gates the roster); each seat may add its own PIN. -->
|
||||
|
||||
Reference in New Issue
Block a user