feat(link): auto-merge a guest initiator into its durable account (seamless sign-in)

When a guest links an identity (email/TG/VK) that already belongs to a durable account,
the guest-primary rule already made the durable account the survivor and switched the
session — but the client still showed an irreversible 'merge two accounts?' confirmation,
which is nonsense from the user's side (they are simply signing into their account). The
confirm step now merges inline for a GUEST initiator and returns the completed merge (the
switched token); a durable initiator still gets the explicit confirmation (consolidating
two real accounts is consequential). The active-game guard still refuses, surfaced as the
clear error.merge_active_game_conflict message rather than swallowed.

Also fixes the merged-away device-local games: their human/host seat was recorded under
the retired account id, so after the switch the lobby and game header could not identify
'me' and showed every seat as an opponent (the game still played — turn logic is
seat-index based). applyLinkResult now re-points local game seats from the retired id to
the survivor (repointLocalGameSeats).

Docs: FUNCTIONAL.md (+_ru).
This commit is contained in:
Ilia Denisov
2026-07-15 00:34:58 +02:00
parent 48614e622d
commit da25eac070
9 changed files with 127 additions and 27 deletions
+39 -9
View File
@@ -347,8 +347,10 @@ func TestAccountLinkEmailMergeIntoCaller(t *testing.T) {
}
}
// TestAccountLinkGuestInversion merges a guest initiator into the durable account
// that owns the email: the durable account wins and a fresh session is minted.
// TestAccountLinkGuestInversion auto-merges a guest initiator into the durable account that
// owns the email AT THE CONFIRM STEP (no merge confirmation): the guest is retired, the durable
// account wins and a fresh session is minted for it (the client adopts the switch and lands on
// the durable account as if it simply logged in).
func TestAccountLinkGuestInversion(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
@@ -364,17 +366,18 @@ func TestAccountLinkGuestInversion(t *testing.T) {
t.Fatalf("request: %v", err)
}
code := sixDigit.FindString(mailer.lastBody)
if _, err := links.ConfirmEmail(ctx, guest, email, code); err != nil {
confirm, err := links.ConfirmEmail(ctx, guest, email, code)
if err != nil {
t.Fatalf("confirm: %v", err)
}
merge, err := links.MergeEmail(ctx, guest, email, code)
if err != nil {
t.Fatalf("merge: %v", err)
// A guest initiator does not get a MergeRequired confirmation — the merge runs inline.
if !confirm.Merged || confirm.MergeRequired {
t.Fatalf("confirm = %+v, want an inline (auto) merge", confirm)
}
if merge.PrimaryID != durable {
t.Fatalf("primary = %s, want durable %s", merge.PrimaryID, durable)
if confirm.Merge.PrimaryID != durable {
t.Fatalf("primary = %s, want durable %s", confirm.Merge.PrimaryID, durable)
}
if merge.SwitchedToken == "" {
if confirm.Merge.SwitchedToken == "" {
t.Error("a guest initiator whose durable counterpart wins must get a switched session token")
}
if mergedInto(t, guest) != durable {
@@ -385,6 +388,33 @@ func TestAccountLinkGuestInversion(t *testing.T) {
}
}
// TestAccountLinkGuestAutoMergeActiveGameConflict guards that a guest's auto-merge is REFUSED
// (not silently swallowed) at the confirm step when the guest and the durable account share an
// active game: the caller surfaces ErrActiveGameConflict (mapped to a clear "finish that game
// first" message) rather than seating one player against themselves.
func TestAccountLinkGuestAutoMergeActiveGameConflict(t *testing.T) {
ctx := context.Background()
mailer := &capturingMailer{}
links := newLinkService(mailer)
durable := provisionAccount(t)
email := "conflict-" + uuid.NewString() + "@example.com"
bindEmailIdentity(t, durable, email)
guest := provisionGuest(t)
seatGame(t, []uuid.UUID{durable, guest}, 24*time.Hour) // an active game the two share
if err := links.RequestEmail(ctx, guest, email); err != nil {
t.Fatalf("request: %v", err)
}
code := sixDigit.FindString(mailer.lastBody)
if _, err := links.ConfirmEmail(ctx, guest, email, code); err != accountmerge.ErrActiveGameConflict {
t.Fatalf("confirm = %v, want ErrActiveGameConflict surfaced by the auto-merge", err)
}
if mergedInto(t, guest) != uuid.Nil {
t.Error("a refused auto-merge must not tombstone the guest")
}
}
// TestAccountLinkFreeVK binds a free VK identity (gateway-validated, no code) and
// promotes a guest to durable — the ConfirmVK counterpart of the free-email case.
func TestAccountLinkFreeVK(t *testing.T) {
+32 -6
View File
@@ -31,13 +31,39 @@ func NewService(emails *account.EmailService, accounts *account.Store, merger *a
return &Service{emails: emails, accounts: accounts, merger: merger, sessions: sessions}
}
// ConfirmResult reports the outcome of a confirm step. Exactly one of Linked or
// MergeRequired is set; SecondaryID is the account to be retired when a merge is
// required (the caller renders an irreversible-merge confirmation from it).
// ConfirmResult reports the outcome of a confirm step. Exactly one of Linked,
// MergeRequired or Merged is set. SecondaryID is the account to be retired when a merge
// is required (the caller renders an irreversible-merge confirmation from it). Merged is
// set when the caller was a guest, so the merge ran inline (see confirmOrAutoMerge) and
// Merge carries its result (the switched-session token for the surviving durable account).
type ConfirmResult struct {
Linked bool
MergeRequired bool
SecondaryID uuid.UUID
Merged bool
Merge MergeResult
}
// confirmOrAutoMerge decides a required merge at the confirm step. A durable caller gets
// the explicit MergeRequired confirmation (consolidating two real accounts is irreversible
// and consequential — the user must see it). A GUEST caller does not: by the guest-primary
// rule the durable other account survives and the ephemeral guest is retired, folding its
// games/wallet/stats in — from the user's side it is simply "logged into my account", so the
// merge runs inline and the client just adopts the switched session. The active-game guard can
// still refuse (accountmerge.ErrActiveGameConflict), surfaced to the caller unchanged.
func (s *Service) confirmOrAutoMerge(ctx context.Context, callerID, owner uuid.UUID) (ConfirmResult, error) {
caller, err := s.accounts.GetByID(ctx, callerID)
if err != nil {
return ConfirmResult{}, err
}
if !caller.IsGuest {
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
}
res, err := s.merge(ctx, callerID, owner)
if err != nil {
return ConfirmResult{}, err
}
return ConfirmResult{Merged: true, Merge: res}, nil
}
// MergeResult reports a completed merge. PrimaryID is the surviving account.
@@ -67,7 +93,7 @@ func (s *Service) ConfirmEmail(ctx context.Context, accountID uuid.UUID, email,
}
return ConfirmResult{Linked: true}, nil
}
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
return s.confirmOrAutoMerge(ctx, accountID, owner)
}
// MergeEmail re-verifies the code and merges the address's account into the
@@ -103,7 +129,7 @@ func (s *Service) ConfirmTelegram(ctx context.Context, callerID uuid.UUID, exter
if owner == callerID {
return ConfirmResult{Linked: true}, nil
}
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
return s.confirmOrAutoMerge(ctx, callerID, owner)
}
// MergeTelegram merges the account owning a gateway-validated Telegram identity
@@ -150,7 +176,7 @@ func (s *Service) ConfirmVK(ctx context.Context, callerID uuid.UUID, externalID
if owner == callerID {
return ConfirmResult{Linked: true}, nil
}
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
return s.confirmOrAutoMerge(ctx, callerID, owner)
}
// MergeVK merges the account owning a gateway-validated VK identity into the caller's
+6
View File
@@ -285,6 +285,12 @@ func (s *Server) handleLinkVKMerge(c *gin.Context) {
// or a completed link (the active account's refreshed profile).
func (s *Server) confirmResultResponse(c *gin.Context, activeID uuid.UUID, res link.ConfirmResult) linkResultResponse {
ctx := c.Request.Context()
if res.Merged {
// A guest initiator's merge ran inline (the guest-primary rule; no confirmation step),
// so render the completed merge — the client adopts the switched session and lands on
// the surviving durable account as if it simply logged in.
return s.mergeResultResponse(c, res.Merge)
}
if res.MergeRequired {
out := linkResultResponse{Status: "merge_required", SecondaryUserID: res.SecondaryID.String()}
if acc, err := s.accounts.GetByID(ctx, res.SecondaryID); err == nil {
+9 -6
View File
@@ -117,12 +117,15 @@ First platform contact auto-provisions a durable account. From the profile a pla
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.
identity already belongs to another account, the two accounts are merged into one
(statistics summed, games and friends transferred, wallet folded, duplicates removed).
A **durable** initiator is shown an explicit, **irreversible** confirmation first —
consolidating two real accounts is consequential. A **guest** initiator is not: the
durable account that owns the identity is kept, and the ephemeral guest — with its games,
wallet and stats folded in — is retired **seamlessly**, so from the player's side it is
simply signing into their account (the app switches to it with no prompt). A merge is
blocked only while the two accounts share a game still in progress; a guest is then asked
to finish that shared game before signing in.
The profile lists the account's **sign-in methods**. On the web a player can add
Telegram (a login-widget popup) or VK (VK ID web login — a redirect to VK's sign-in and
+8 -6
View File
@@ -121,12 +121,14 @@ Telegram держит **единого бота**: все игроки поль
привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость,
привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже
занята» не раскрывается до проверки кода/входа. Если привязываемая личность уже
принадлежит другому аккаунту, игроку показывают явное **необратимое**
подтверждение, и два аккаунта сливаются в тот, под которым он сейчас работает
(статистика суммируется, игры и друзья переносятся, дубликаты убираются), — кроме
случая, когда гость привязывает личность с уже существующим постоянным аккаунтом:
тогда сохраняется постоянный аккаунт, а игры гостя переходят в него. Слияние
запрещено, только пока у аккаунтов есть общая незавершённая игра.
принадлежит другому аккаунту, два аккаунта сливаются в один (статистика суммируется,
игры и друзья переносятся, кошелёк складывается, дубликаты убираются). **Постоянному**
инициатору сначала показывают явное **необратимое** подтверждение — объединение двух
настоящих аккаунтов важно. **Гостю** — нет: сохраняется постоянный аккаунт-владелец
личности, а эфемерный гость (с его играми, кошельком и статистикой) ретайрится
**бесшовно**, так что со стороны игрока это просто вход в свой аккаунт (приложение
переключается на него без запроса). Слияние запрещено, только пока у аккаунтов есть
общая незавершённая игра; гостя тогда просят сперва доиграть эту общую партию.
В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить
Telegram (попап логин-виджета) или VK (веб-вход VK ID — редирект на страницу входа VK и
+11
View File
@@ -662,7 +662,18 @@ async function reconcileServerGuest(): Promise<void> {
*/
export async function applyLinkResult(r: LinkResult): Promise<void> {
if (r.session && r.session.token) {
// A guest initiator's merge switches the session to the surviving durable account. Its
// device-local games (vs_ai / hotseat) were seated under the retired account's id, so
// re-point them to the survivor: the lobby and the game header identify "me" by the active
// account, and without this the merged-away game shows every seat as an opponent (the
// in-game turn logic is seat-index based, so it still plays — the summary just misreads).
const retiredId = app.session?.userId;
await adoptSession(r.session);
const survivorId = app.session?.userId;
if (retiredId && survivorId && retiredId !== survivorId) {
const { repointLocalGameSeats } = await import('./localgame/store');
await repointLocalGameSeats(retiredId, survivorId);
}
return;
}
app.profile = await gateway.profileGet();
+1
View File
@@ -312,6 +312,7 @@ export const en = {
'error.chat_rejected': 'Message rejected (too long or contains contact info).',
'error.nudge_too_soon': "Please don't rush your opponent so often.",
'error.chat_not_your_turn': 'You can chat only on your turn.',
'error.merge_active_game_conflict': 'You have an unfinished game with that account. Finish it, then sign in again.',
'error.chat_already_sent': 'You can send only one message per turn.',
'error.game_finished': 'This game is finished.',
'error.not_a_player': 'You are not a player in this game.',
+1
View File
@@ -312,6 +312,7 @@ export const ru: Record<MessageKey, string> = {
'error.chat_rejected': 'Сообщение отклонено (слишком длинное или содержит контакты).',
'error.nudge_too_soon': 'Не стоит торопить соперника так часто.',
'error.chat_not_your_turn': 'Писать в чат можно только в свой ход.',
'error.merge_active_game_conflict': 'У вас есть незавершённая партия с этим аккаунтом. Доиграйте её и войдите снова.',
'error.chat_already_sent': 'За один ход можно отправить только одно сообщение.',
'error.game_finished': 'Эта игра уже завершена.',
'error.not_a_player': 'Вы не участник этой игры.',
+20
View File
@@ -81,6 +81,26 @@ export async function listLocalGames(): Promise<LocalGameRecord[]> {
}
}
/** repointLocalGameSeats rewrites the account id on every stored game's seats from oldId to
* newId. It is run when an account merge retires the account a device-local game was seated
* under (a guest that logged into a durable account): the surviving account then owns the
* games, so the lobby and the game header identify "me" again — without it the human seat no
* longer matches the active account and the game shows every seat as an opponent. Best-effort;
* the records read from IndexedDB are plain objects, so mutating and re-saving them is safe. */
export async function repointLocalGameSeats(oldId: string, newId: string): Promise<void> {
if (!oldId || !newId || oldId === newId) return;
for (const g of await listLocalGames()) {
let touched = false;
for (const s of g.seats) {
if (s.accountId === oldId) {
s.accountId = newId;
touched = true;
}
}
if (touched) await saveLocalGame(g);
}
}
/** deleteLocalGame removes a game record, swallowing any failure (best-effort). */
export async function deleteLocalGame(id: string): Promise<void> {
const db = openDb();