Stage 11: account linking & merge (email + Telegram Login Widget) (#12)
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 11s
Tests · UI / test (push) Successful in 18s

This commit was merged in pull request #12.
This commit is contained in:
2026-06-04 09:18:17 +00:00
parent 3a640a17a4
commit 01485d8fc6
68 changed files with 3331 additions and 369 deletions
+33 -13
View File
@@ -113,7 +113,9 @@ arrive from a platform rather than completing a mandatory registration).
records live in `backend`, which stores only a **SHA-256 hash** of the opaque
token (never the plaintext), keeps a warmed in-memory cache for fast
resolution, and treats sessions as **revoke-only** — they have no TTL and live
until explicitly revoked (`status``revoked`).
until explicitly revoked (`status``revoked`). A revoke can target one token or,
on an account merge (§4), **every** session of the retired account
(`RevokeAllForAccount`, which also evicts them from the warm cache).
- **Guest** = ephemeral web session (no platform, no email). A guest is backed by
a durable `accounts` row flagged `is_guest` and carrying **no identity** — the
row is a technical necessity (the `sessions` and `game_players` foreign keys
@@ -134,17 +136,33 @@ arrive from a platform rather than completing a mandatory registration).
authenticated account: a 6-digit code (stored only as a SHA-256 hash, 15-minute
TTL, ≤ 5 attempts) is sent through a `Mailer` seam (an SMTP relay, or a
development log mailer when none is configured) and, once verified, attaches a
confirmed email identity. An email already confirmed by **another** account is
refused — adopting it would be a merge, which Stage 11 owns. Accounts and
identities use application-generated **UUIDv7** primary keys.
- **Linking** is initiated from an authenticated profile: choose a platform →
complete that platform's web-auth confirm → attach the identity to the
current account.
- **Merge**: if the identity being linked already has its own account with
history, the two accounts are **merged into the current one (A is primary)**:
statistics are summed, games and friends are transferred, duplicates are
de-duplicated, the secondary account is retired. High blast-radius; an
isolated, well-tested stage.
confirmed email identity. Accounts and identities use application-generated
**UUIDv7** primary keys. A service flag `paid_account` (lifetime one-time
payment; no purchase flow yet) is carried on the account and ORed on a merge.
- **Linking** (Stage 11) is initiated from an authenticated profile and proves
control of the identity before attaching it: **email** through the confirm-code
flow, **Telegram** through the web **Login Widget** (validated by the connector,
HMAC under `SHA-256(bot_token)` — distinct from Mini App initData; the gateway
passes the trusted `external_id` to the backend, as for `auth.telegram`). The
request step **always** sends/accepts the proof (no pre-send "already taken"
signal, so a probe cannot enumerate registered addresses); a required **merge**
is revealed **only after** the proof is verified and is performed behind an
explicit, irreversible confirmation. A free identity is simply attached (and a
guest is promoted to durable, clearing `is_guest`).
- **Merge** retires the account that owns the linked identity into the **current**
account, in a single transaction (`internal/accountmerge`): statistics summed
(max points kept), the hint wallet summed, `paid_account` ORed, identities
repointed, games / chat / complaints transferred, friends and blocks
de-duplicated (friendships keep the strongest status accepted>pending>declined),
pending invitations/codes dropped, and the secondary kept as an **audit
tombstone** (`accounts.merged_into`/`merged_at`) so a shared **finished** game's
no-cascade foreign keys stay valid — its seat there is left untouched. A merge is
**refused** only when the two share an **active** game. The current account is the
primary, **except** when the initiator is a **guest** and the linked identity
already has a **durable** owner: then the durable account wins, the guest's active
games move into it, the guest is retired, and a **fresh session** is minted for the
durable account (the client switches to it). The secondary's sessions are revoked
(§3). High blast-radius; an isolated, well-tested stage.
## 5. Game engine integration (`scrabble-solver`)
@@ -337,7 +355,9 @@ requires (there is no DM surface; chat is per-game).
- Tables: `accounts` (durable internal accounts; Stage 3 added the away-window
columns `away_start`/`away_end` and the hint wallet `hint_balance`; Stage 6's
migration `00005` added the `is_guest` flag for ephemeral guest rows; Stage 9's
migration `00007` added the `notifications_in_app_only` out-of-app push toggle),
migration `00007` added the `notifications_in_app_only` out-of-app push toggle;
Stage 11's migration `00009` added the `paid_account` service flag and the
merge-tombstone columns `merged_into`/`merged_at`),
`identities` (platform/email/robot identities, unique `(kind, external_id)`;
Stage 5's migration `00004` admits the `robot` kind),
`sessions` (revoke-only opaque-token hashes), the Stage 3 game tables
+14 -10
View File
@@ -36,11 +36,17 @@ out-of-app events (your turn, nudge, a found match, an invitation or friend requ
arrive as a **Telegram notification** instead — unless the player keeps notifications
in the app only (a profile setting, **on by default**).
### Accounts, linking & merge *(Stage 1 / 10)*
First platform contact auto-provisions a durable account. From the profile a
player links additional platform identities or an email via a confirm flow;
linking an identity that already has history merges it into the current
account (stats summed, games/friends transferred).
### Accounts, linking & merge *(Stage 1 / 11)*
First platform contact auto-provisions a durable account. From the profile a player
links an email (via a confirm code) or their Telegram (via the web sign-in); a guest
who links their first identity becomes a durable account. The "already taken" status
of an identity is never revealed before the code/sign-in is verified. If the linked
identity already belongs to another account, the player is shown an explicit,
**irreversible** confirmation and the two accounts are merged into the one they are
using (statistics summed, games and friends transferred, duplicates removed) — except
when a guest links an identity that already has a durable account, where the durable
account is kept and the guest's games move into it. A merge is blocked only while the
two accounts share a game still in progress.
### Lobby & matchmaking *(Stage 4)*
Bottom tab menu: **my games**, **profile**. Auto-match (always 2 players) joins a
@@ -95,11 +101,9 @@ nudge is part of the game chat); the out-of-app push is delivered via the platfo
### Profile & settings *(Stage 4 / 8)*
Edit the display name (letters joined by single space / "." / "_" separators, up to
32 characters), the timezone (chosen as a UTC offset), the daily away window (on a
10-minute grid, at most 12 hours, wrapping midnight) and the block toggles, and bind
an email by confirm-code: the backend emails a short code that,
once entered, attaches the email to the account (an email already confirmed by
another account cannot be taken — that is a merge, a later stage). Linked platform
accounts and merge arrive in Stage 11.
10-minute grid, at most 12 hours, wrapping midnight) and the block toggles. Linking
an email or Telegram and merging accounts are covered under "Accounts, linking &
merge" (Stage 11).
### History & statistics *(Stage 3 / 8)*
Finished games are archived in a dictionary-independent form and exportable to
+12 -9
View File
@@ -37,11 +37,17 @@ Mini App** авторизует по подписанным `initData` плат
вместо этого **уведомлением в Telegram** — если только игрок не оставил уведомления
только в приложении (настройка профиля, **включена по умолчанию**).
### Аккаунты, привязка и слияние *(Stage 1 / 10)*
### Аккаунты, привязка и слияние *(Stage 1 / 11)*
Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок
привязывает другие платформенные личности или email через confirm-поток;
привязка личности, у которой уже есть история, сливает её в текущий аккаунт
(статистика суммируется, игры/друзья переносятся).
привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость,
привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже
занята» не раскрывается до проверки кода/входа. Если привязываемая личность уже
принадлежит другому аккаунту, игроку показывают явное **необратимое**
подтверждение, и два аккаунта сливаются в тот, под которым он сейчас работает
(статистика суммируется, игры и друзья переносятся, дубликаты убираются), — кроме
случая, когда гость привязывает личность с уже существующим постоянным аккаунтом:
тогда сохраняется постоянный аккаунт, а игры гостя переходят в него. Слияние
запрещено, только пока у аккаунтов есть общая незавершённая игра.
### Лобби и подбор *(Stage 4)*
Нижнее tab-меню: **мои игры**, **профиль**. Авто-подбор (всегда 2 игрока)
@@ -98,11 +104,8 @@ push доставляется через платформу.
Редактирование отображаемого имени (буквы, разделённые одиночными пробелом / «.» /
«_», до 32 символов), таймзоны (выбор смещения от UTC), суточного окна отсутствия
(away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и
переключателей блокировок, а также привязка email по confirm-коду: backend шлёт на
почту короткий код, и после ввода email
привязывается к аккаунту (email, уже подтверждённый другим аккаунтом, занять
нельзя — это слияние, отдельный этап). Привязанные платформенные аккаунты и
слияние появятся в Stage 11.
переключателей блокировок. Привязка email и Telegram, а также слияние аккаунтов
вынесены в раздел «Аккаунты, привязка и слияние» (Stage 11).
### История и статистика *(Stage 3 / 8)*
Завершённые партии архивируются в независимом от словаря виде и экспортируются