diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0079a17..2c0a3f4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -42,7 +42,12 @@ Three executables plus per-platform side-services: users, a weighted fair rotation — §10), and a client **board-style** setting (bonus-label mode). The visual/interaction design system is documented in - [`UI_DESIGN.md`](UI_DESIGN.md). + [`UI_DESIGN.md`](UI_DESIGN.md). Inside the Telegram Mini App the client additionally + tracks Telegram's live theme switch (`themeChanged`), fits the full device safe-area + insets (the bottom/home-indicator strip taking the bottom bar's colour), exposes + Telegram's native **Settings** button into the in-app settings, and syncs the + device-independent display preferences (theme, reduce-motion, board labels — **not** the + interface language) across the user's Telegram devices via **CloudStorage**. - **`platform/telegram`** — the Telegram side-service (module `scrabble/platform/telegram`), split into two binaries that share the bot token (**one bot**, one optional game channel, §3): @@ -152,7 +157,8 @@ arrive from a platform rather than completing a mandatory registration). - **Single bot.** The platform side-service runs **one bot** (one token + one optional game channel), split into a home **validator** and a remote **bot** that share the token. `ValidateInitData` (the validator) validates `initData` against that single - token and returns only the Telegram user identity — there is no per-bot "service + token, **rejects a bot user** (the signed `is_bot` flag), and returns only the Telegram + user identity — there is no per-bot "service language" and no supported-languages set on the wire. The bot's chat messages and out-of-app push are rendered in the recipient's **interface language** (`preferred_language`, en/ru), not in @@ -972,7 +978,7 @@ edits take effect on the next `profile.get` (open/reconnect/foreground), not mid | Concern | Enforced by | | --- | --- | | Public rate limiting / anti-abuse | gateway (per-IP public/email/admin classes, per-user authenticated class; a request body cap of `GATEWAY_MAX_BODY_BYTES`; rejections are metered, summarised to the backend and surfaced in the admin console with a conservative reversible auto-flag — §11). In prod a **temporary IP ban** (`GATEWAY_ABUSE_BAN_ENABLED`) blocks an IP that sustains rejections or trips a **honeypot** decoy path / **honeytoken**, refused with 429 before any work; operators lift bans from the console. Off in the shared-NAT test contour, where the client IP is not real (§11) | -| Telegram initData validation (bot-token HMAC) | the Telegram **validator**; the gateway delegates it over gRPC, so the bot token (the HMAC secret) lives only in the validator and the bot, never in the gateway | +| Telegram initData validation (bot-token HMAC) | the Telegram **validator**; the gateway delegates it over gRPC, so the bot token (the HMAC secret) lives only in the validator and the bot, never in the gateway. The validator also **rejects a bot principal** (the signed `is_bot` flag) before any account is provisioned | | Session minting; email-code / guest validation | gateway (with backend) | | Session → `user_id` resolution, `X-User-ID` injection | gateway | | Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) | diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index c21c38e..4b242b0 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -30,7 +30,8 @@ A player arrives from a platform (Telegram first), via email login, or as an ephemeral guest. The gateway validates the credential once and mints a thin session token; the backend resolves it to an internal `user_id`. A **Telegram Mini App** launch authenticates from the platform's signed `initData`, themes the UI to -the Telegram colours, and — on first contact — seeds the new account's interface +the Telegram colours (re-theming live if you switch Telegram's light/dark mode) and fits +the device safe-area, and — on first contact — seeds the new account's interface language from the Telegram client. If a launch cannot reach the backend (for example during a deployment), the Mini App retries quietly and then shows a small "couldn't load" screen with a **Retry** button, rather than dropping to the web sign-in, which has no place inside Telegram. @@ -249,7 +250,10 @@ is first created — so robot games are timed correctly before you ever open thi daily away window (on a 10-minute grid, at most 12 hours, wrapping midnight) and the block toggles. The profile form is edited inline (no separate edit mode). Linking an email or Telegram and merging accounts are covered under "Accounts, linking & -merge". +merge". Inside the Telegram Mini App, Telegram's own ⋮ menu also offers a **Settings** +entry that opens this screen, and your display preferences (theme, board-label style and +reduce-motion — not the interface language, which follows your account) sync across your +Telegram devices. **Preferences (which variants you can be matched into).** A profile setting picks the game variants — Erudite, Russian Scrabble and English Scrabble, shown **Erudite-first** — you allow diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index c611b7a..f06af8c 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -31,7 +31,9 @@ top-1 подсказку, безлимитную проверку слова с эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий session-токен; backend сопоставляет его с внутренним `user_id`. Запуск **Telegram Mini App** авторизует по подписанным `initData` платформы, перекрашивает интерфейс -в цвета Telegram и — при первом контакте — задаёт язык интерфейса нового аккаунта по +в цвета Telegram (перекрашиваясь вживую при смене светлой/тёмной темы Telegram) и +вписывается в безопасные зоны экрана (safe-area), а — при первом контакте — задаёт язык +интерфейса нового аккаунта по языку Telegram-клиента. Если запуск не может достучаться до бэкенда (например, во время деплоя), Mini App тихо повторяет попытки, а затем показывает небольшой экран «не удалось загрузить» с кнопкой **Повторить**, вместо того чтобы сбрасывать на веб-вход, которому внутри @@ -255,7 +257,11 @@ UTC; при создании аккаунта она подставляется игры с роботом таймились правильно ещё до открытия этой формы), суточного окна отсутствия (away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и переключателей блокировок. Форма профиля редактируется сразу (без отдельного режима редактирования). Привязка email и Telegram, а также -слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние». +слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние». Внутри Telegram +Mini App пункт **Settings** в системном меню «⋮» Telegram также открывает этот экран, а +ваши настройки отображения (тема, стиль подписей клеток и reduce-motion — кроме языка +интерфейса, который следует за аккаунтом) синхронизируются между вашими устройствами в +Telegram. **Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в diff --git a/platform/telegram/internal/initdata/validator.go b/platform/telegram/internal/initdata/validator.go index d09c8e3..a4d35ba 100644 --- a/platform/telegram/internal/initdata/validator.go +++ b/platform/telegram/internal/initdata/validator.go @@ -18,7 +18,8 @@ import ( ) // ErrInvalidInitData is returned when initData fails HMAC validation, is missing -// the hash, is malformed, or is older than the freshness window. +// the hash, is malformed, is older than the freshness window, or identifies a bot +// user (is_bot), which is denied. var ErrInvalidInitData = errors.New("initdata: invalid telegram init data") // defaultMaxAge bounds how old a validated initData payload may be. @@ -120,6 +121,7 @@ func parseUser(userJSON string) (User, error) { } var u struct { ID int64 `json:"id"` + IsBot bool `json:"is_bot"` Username string `json:"username"` FirstName string `json:"first_name"` LanguageCode string `json:"language_code"` @@ -127,6 +129,12 @@ func parseUser(userJSON string) (User, error) { if err := json.Unmarshal([]byte(userJSON), &u); err != nil || u.ID == 0 { return User{}, ErrInvalidInitData } + // Deny bot principals: the HMAC has already proved Telegram signed this payload, so is_bot==true + // is Telegram itself attesting the launching user is a bot. A real user opening the Mini App + // never carries it, so reject defensively rather than provision an account for a bot. + if u.IsBot { + return User{}, ErrInvalidInitData + } return User{ ExternalID: strconv.FormatInt(u.ID, 10), Username: u.Username, diff --git a/platform/telegram/internal/initdata/validator_test.go b/platform/telegram/internal/initdata/validator_test.go index 9f525cd..82b4065 100644 --- a/platform/telegram/internal/initdata/validator_test.go +++ b/platform/telegram/internal/initdata/validator_test.go @@ -83,3 +83,28 @@ func TestValidateRejects(t *testing.T) { } }) } + +func TestValidateBotUser(t *testing.T) { + t.Run("is_bot true is denied", func(t *testing.T) { + initData := signInitData(testToken, map[string]string{ + "auth_date": strconv.FormatInt(time.Now().Unix(), 10), + "user": `{"id":42,"is_bot":true,"first_name":"Robo"}`, + }) + if _, err := NewHMACValidator(testToken).Validate(initData); !errors.Is(err, ErrInvalidInitData) { + t.Errorf("err = %v, want ErrInvalidInitData", err) + } + }) + t.Run("is_bot false is allowed", func(t *testing.T) { + initData := signInitData(testToken, map[string]string{ + "auth_date": strconv.FormatInt(time.Now().Unix(), 10), + "user": `{"id":42,"is_bot":false,"first_name":"Thomas"}`, + }) + u, err := NewHMACValidator(testToken).Validate(initData) + if err != nil { + t.Fatalf("validate: %v", err) + } + if u.ExternalID != "42" || u.FirstName != "Thomas" { + t.Errorf("user = %+v, want {42 Thomas}", u) + } + }) +} diff --git a/ui/src/app.css b/ui/src/app.css index c2fb4ba..46a1d44 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -49,6 +49,11 @@ /* Telegram device safe-area top (the notch); TG's own nav controls sit between it and --tg-content-top, so the in-app header aligns to that band, 0 elsewhere. */ --tg-safe-top: 0px; + /* Telegram device safe-area bottom / sides (home indicator; landscape notch), 0 elsewhere — + the screen pads its bottom and left/right edges by these so content clears the cut-outs. */ + --tg-safe-bottom: 0px; + --tg-safe-left: 0px; + --tg-safe-right: 0px; --font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif; --shadow: 0 1px 2px rgba(0, 0, 0, 0.08), 0 6px 16px rgba(0, 0, 0, 0.06); diff --git a/ui/src/components/Screen.svelte b/ui/src/components/Screen.svelte index f428b59..1fa31c0 100644 --- a/ui/src/components/Screen.svelte +++ b/ui/src/components/Screen.svelte @@ -101,6 +101,12 @@ bottom input — chat, word-check — stays above an open soft keyboard without the page scrolling; falls back to the full height where the var is unset. */ height: var(--vvh, 100%); + /* Clear the landscape notch sides inside Telegram (0 elsewhere). The top inset is owned by the + header; the home-indicator (bottom) inset is owned by the bottom bar — the .tabbar paints its + own chrome into it, and a screen with no tab bar pads its content (.content:last-child) — so + the strip takes the bar's colour rather than the detached content background. */ + padding-left: var(--tg-safe-left, 0px); + padding-right: var(--tg-safe-right, 0px); } .content { flex: 0 1 auto; @@ -116,7 +122,18 @@ display: flex; flex-direction: column; } + /* No tab bar → the content is the bottom-most element: pad it by the device home-indicator inset + so it clears the cut-out, the strip taking the content's own background. With a tab bar the + .tabbar owns that inset instead (and content is not the last child, so this does not apply). */ + .content:last-child { + padding-bottom: var(--tg-safe-bottom, 0px); + } .tabbar { flex: 0 0 auto; + /* Extend the bottom bar's chrome (the TabBar's --bg-elev) under the device home indicator + inside Telegram (the inset is 0 elsewhere), so the safe-area strip reads as part of the bar + instead of the content background showing through. */ + background: var(--bg-elev); + padding-bottom: var(--tg-safe-bottom, 0px); } diff --git a/ui/src/components/StaleInviteModal.svelte b/ui/src/components/StaleInviteModal.svelte index f8a266b..1ababd8 100644 --- a/ui/src/components/StaleInviteModal.svelte +++ b/ui/src/components/StaleInviteModal.svelte @@ -1,8 +1,10 @@ -{#if app.staleInvite} +{#if app.staleInvite && ready && !(insideTelegram() && telegramDialogsAvailable())}

{parts[0]}{#if username}{/if}{parts[1] ?? ''}

diff --git a/ui/src/components/WelcomeRedeemModal.svelte b/ui/src/components/WelcomeRedeemModal.svelte index 7783c5c..9cbc2d9 100644 --- a/ui/src/components/WelcomeRedeemModal.svelte +++ b/ui/src/components/WelcomeRedeemModal.svelte @@ -1,8 +1,10 @@ -{#if app.welcomeRedeem} +{#if app.welcomeRedeem && ready && !(insideTelegram() && telegramDialogsAvailable())}

{parts[0]}{#if username}{/if}{parts[1] ?? ''}

diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 67e1a09..5170c49 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -24,7 +24,7 @@ import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache'; import { patchLobbyGame } from '../lib/lobbycache'; import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta'; - import { telegramHaptic } from '../lib/telegram'; + import { insideTelegram, telegramDialogsAvailable, telegramHaptic, telegramShowConfirm } from '../lib/telegram'; import { BLANK, newPlacement, @@ -712,6 +712,16 @@ busy = false; } } + // onResignClick: inside the Mini App (and online) confirm with Telegram's native dialog and resign + // on accept; otherwise open the in-app confirm modal (which also carries the offline-disabled action). + async function onResignClick(): Promise { + if (connection.online && insideTelegram() && telegramDialogsAvailable()) { + if (await telegramShowConfirm(t('game.confirmResign'))) doResign(); + return; + } + resignOpen = true; + } + async function doResign() { resignOpen = false; busy = true; @@ -1149,7 +1159,7 @@ {/if} {:else} - + {/if} {#if !view.game.multipleWordsPerTurn}{t('game.oneWordRule')}{/if}