release: promote development → master (v1.22.0) #274
@@ -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