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:
@@ -347,8 +347,10 @@ func TestAccountLinkEmailMergeIntoCaller(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAccountLinkGuestInversion merges a guest initiator into the durable account
|
// TestAccountLinkGuestInversion auto-merges a guest initiator into the durable account that
|
||||||
// that owns the email: the durable account wins and a fresh session is minted.
|
// 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) {
|
func TestAccountLinkGuestInversion(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
store := account.NewStore(testDB)
|
store := account.NewStore(testDB)
|
||||||
@@ -364,17 +366,18 @@ func TestAccountLinkGuestInversion(t *testing.T) {
|
|||||||
t.Fatalf("request: %v", err)
|
t.Fatalf("request: %v", err)
|
||||||
}
|
}
|
||||||
code := sixDigit.FindString(mailer.lastBody)
|
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)
|
t.Fatalf("confirm: %v", err)
|
||||||
}
|
}
|
||||||
merge, err := links.MergeEmail(ctx, guest, email, code)
|
// A guest initiator does not get a MergeRequired confirmation — the merge runs inline.
|
||||||
if err != nil {
|
if !confirm.Merged || confirm.MergeRequired {
|
||||||
t.Fatalf("merge: %v", err)
|
t.Fatalf("confirm = %+v, want an inline (auto) merge", confirm)
|
||||||
}
|
}
|
||||||
if merge.PrimaryID != durable {
|
if confirm.Merge.PrimaryID != durable {
|
||||||
t.Fatalf("primary = %s, want durable %s", 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")
|
t.Error("a guest initiator whose durable counterpart wins must get a switched session token")
|
||||||
}
|
}
|
||||||
if mergedInto(t, guest) != durable {
|
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
|
// TestAccountLinkFreeVK binds a free VK identity (gateway-validated, no code) and
|
||||||
// promotes a guest to durable — the ConfirmVK counterpart of the free-email case.
|
// promotes a guest to durable — the ConfirmVK counterpart of the free-email case.
|
||||||
func TestAccountLinkFreeVK(t *testing.T) {
|
func TestAccountLinkFreeVK(t *testing.T) {
|
||||||
|
|||||||
@@ -31,13 +31,39 @@ func NewService(emails *account.EmailService, accounts *account.Store, merger *a
|
|||||||
return &Service{emails: emails, accounts: accounts, merger: merger, sessions: sessions}
|
return &Service{emails: emails, accounts: accounts, merger: merger, sessions: sessions}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfirmResult reports the outcome of a confirm step. Exactly one of Linked or
|
// ConfirmResult reports the outcome of a confirm step. Exactly one of Linked,
|
||||||
// MergeRequired is set; SecondaryID is the account to be retired when a merge is
|
// MergeRequired or Merged is set. SecondaryID is the account to be retired when a merge
|
||||||
// required (the caller renders an irreversible-merge confirmation from it).
|
// 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 {
|
type ConfirmResult struct {
|
||||||
Linked bool
|
Linked bool
|
||||||
MergeRequired bool
|
MergeRequired bool
|
||||||
SecondaryID uuid.UUID
|
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.
|
// 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{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
|
// 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 {
|
if owner == callerID {
|
||||||
return ConfirmResult{Linked: true}, nil
|
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
|
// 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 {
|
if owner == callerID {
|
||||||
return ConfirmResult{Linked: true}, nil
|
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
|
// MergeVK merges the account owning a gateway-validated VK identity into the caller's
|
||||||
|
|||||||
@@ -285,6 +285,12 @@ func (s *Server) handleLinkVKMerge(c *gin.Context) {
|
|||||||
// or a completed link (the active account's refreshed profile).
|
// or a completed link (the active account's refreshed profile).
|
||||||
func (s *Server) confirmResultResponse(c *gin.Context, activeID uuid.UUID, res link.ConfirmResult) linkResultResponse {
|
func (s *Server) confirmResultResponse(c *gin.Context, activeID uuid.UUID, res link.ConfirmResult) linkResultResponse {
|
||||||
ctx := c.Request.Context()
|
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 {
|
if res.MergeRequired {
|
||||||
out := linkResultResponse{Status: "merge_required", SecondaryUserID: res.SecondaryID.String()}
|
out := linkResultResponse{Status: "merge_required", SecondaryUserID: res.SecondaryID.String()}
|
||||||
if acc, err := s.accounts.GetByID(ctx, res.SecondaryID); err == nil {
|
if acc, err := s.accounts.GetByID(ctx, res.SecondaryID); err == nil {
|
||||||
|
|||||||
+9
-6
@@ -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
|
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
|
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
|
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,
|
identity already belongs to another account, the two accounts are merged into one
|
||||||
**irreversible** confirmation and the two accounts are merged into the one they are
|
(statistics summed, games and friends transferred, wallet folded, duplicates removed).
|
||||||
using (statistics summed, games and friends transferred, duplicates removed) — except
|
A **durable** initiator is shown an explicit, **irreversible** confirmation first —
|
||||||
when a guest links an identity that already has a durable account, where the durable
|
consolidating two real accounts is consequential. A **guest** initiator is not: the
|
||||||
account is kept and the guest's games move into it. A merge is blocked only while the
|
durable account that owns the identity is kept, and the ephemeral guest — with its games,
|
||||||
two accounts share a game still in progress.
|
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
|
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
|
Telegram (a login-widget popup) or VK (VK ID web login — a redirect to VK's sign-in and
|
||||||
|
|||||||
@@ -121,12 +121,14 @@ Telegram держит **единого бота**: все игроки поль
|
|||||||
привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость,
|
привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость,
|
||||||
привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже
|
привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже
|
||||||
занята» не раскрывается до проверки кода/входа. Если привязываемая личность уже
|
занята» не раскрывается до проверки кода/входа. Если привязываемая личность уже
|
||||||
принадлежит другому аккаунту, игроку показывают явное **необратимое**
|
принадлежит другому аккаунту, два аккаунта сливаются в один (статистика суммируется,
|
||||||
подтверждение, и два аккаунта сливаются в тот, под которым он сейчас работает
|
игры и друзья переносятся, кошелёк складывается, дубликаты убираются). **Постоянному**
|
||||||
(статистика суммируется, игры и друзья переносятся, дубликаты убираются), — кроме
|
инициатору сначала показывают явное **необратимое** подтверждение — объединение двух
|
||||||
случая, когда гость привязывает личность с уже существующим постоянным аккаунтом:
|
настоящих аккаунтов важно. **Гостю** — нет: сохраняется постоянный аккаунт-владелец
|
||||||
тогда сохраняется постоянный аккаунт, а игры гостя переходят в него. Слияние
|
личности, а эфемерный гость (с его играми, кошельком и статистикой) ретайрится
|
||||||
запрещено, только пока у аккаунтов есть общая незавершённая игра.
|
**бесшовно**, так что со стороны игрока это просто вход в свой аккаунт (приложение
|
||||||
|
переключается на него без запроса). Слияние запрещено, только пока у аккаунтов есть
|
||||||
|
общая незавершённая игра; гостя тогда просят сперва доиграть эту общую партию.
|
||||||
|
|
||||||
В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить
|
В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить
|
||||||
Telegram (попап логин-виджета) или VK (веб-вход VK ID — редирект на страницу входа VK и
|
Telegram (попап логин-виджета) или VK (веб-вход VK ID — редирект на страницу входа VK и
|
||||||
|
|||||||
@@ -662,7 +662,18 @@ async function reconcileServerGuest(): Promise<void> {
|
|||||||
*/
|
*/
|
||||||
export async function applyLinkResult(r: LinkResult): Promise<void> {
|
export async function applyLinkResult(r: LinkResult): Promise<void> {
|
||||||
if (r.session && r.session.token) {
|
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);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
app.profile = await gateway.profileGet();
|
app.profile = await gateway.profileGet();
|
||||||
|
|||||||
@@ -312,6 +312,7 @@ export const en = {
|
|||||||
'error.chat_rejected': 'Message rejected (too long or contains contact info).',
|
'error.chat_rejected': 'Message rejected (too long or contains contact info).',
|
||||||
'error.nudge_too_soon': "Please don't rush your opponent so often.",
|
'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.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.chat_already_sent': 'You can send only one message per turn.',
|
||||||
'error.game_finished': 'This game is finished.',
|
'error.game_finished': 'This game is finished.',
|
||||||
'error.not_a_player': 'You are not a player in this game.',
|
'error.not_a_player': 'You are not a player in this game.',
|
||||||
|
|||||||
@@ -312,6 +312,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'error.chat_rejected': 'Сообщение отклонено (слишком длинное или содержит контакты).',
|
'error.chat_rejected': 'Сообщение отклонено (слишком длинное или содержит контакты).',
|
||||||
'error.nudge_too_soon': 'Не стоит торопить соперника так часто.',
|
'error.nudge_too_soon': 'Не стоит торопить соперника так часто.',
|
||||||
'error.chat_not_your_turn': 'Писать в чат можно только в свой ход.',
|
'error.chat_not_your_turn': 'Писать в чат можно только в свой ход.',
|
||||||
|
'error.merge_active_game_conflict': 'У вас есть незавершённая партия с этим аккаунтом. Доиграйте её и войдите снова.',
|
||||||
'error.chat_already_sent': 'За один ход можно отправить только одно сообщение.',
|
'error.chat_already_sent': 'За один ход можно отправить только одно сообщение.',
|
||||||
'error.game_finished': 'Эта игра уже завершена.',
|
'error.game_finished': 'Эта игра уже завершена.',
|
||||||
'error.not_a_player': 'Вы не участник этой игры.',
|
'error.not_a_player': 'Вы не участник этой игры.',
|
||||||
|
|||||||
@@ -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). */
|
/** deleteLocalGame removes a game record, swallowing any failure (best-effort). */
|
||||||
export async function deleteLocalGame(id: string): Promise<void> {
|
export async function deleteLocalGame(id: string): Promise<void> {
|
||||||
const db = openDb();
|
const db = openDb();
|
||||||
|
|||||||
Reference in New Issue
Block a user