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
+34
View File
@@ -61,6 +61,40 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
return b.FinishedBytes()
}
// encodeLinkResult builds a LinkResult payload (Stage 11). A switched-session token
// (a guest initiator whose durable counterpart won) is carried as a nested Session
// for the client to adopt; it is omitted otherwise.
func encodeLinkResult(r backendclient.LinkResultResp) []byte {
b := flatbuffers.NewBuilder(256)
status := b.CreateString(r.Status)
secID := b.CreateString(r.SecondaryUserID)
secName := b.CreateString(r.SecondaryName)
hasSession := r.Token != "" && r.Profile != nil
var sess flatbuffers.UOffsetT
if hasSession {
token := b.CreateString(r.Token)
uid := b.CreateString(r.Profile.UserID)
name := b.CreateString(r.Profile.DisplayName)
fb.SessionStart(b)
fb.SessionAddToken(b, token)
fb.SessionAddUserId(b, uid)
fb.SessionAddIsGuest(b, r.Profile.IsGuest)
fb.SessionAddDisplayName(b, name)
sess = fb.SessionEnd(b)
}
fb.LinkResultStart(b)
fb.LinkResultAddStatus(b, status)
fb.LinkResultAddSecondaryUserId(b, secID)
fb.LinkResultAddSecondaryDisplayName(b, secName)
fb.LinkResultAddSecondaryGames(b, int32(r.SecondaryGames))
fb.LinkResultAddSecondaryFriends(b, int32(r.SecondaryFriends))
if hasSession {
fb.LinkResultAddSession(b, sess)
}
b.Finish(fb.LinkResultEnd(b))
return b.FinishedBytes()
}
// encodeMoveResult builds a MoveResult payload.
func encodeMoveResult(r backendclient.MoveResultResp) []byte {
b := flatbuffers.NewBuilder(512)
+9 -2
View File
@@ -63,10 +63,13 @@ type Registry struct {
ops map[string]Op
}
// TelegramValidator validates Mini App launch data via the connector side-service.
// *connector.Client implements it; a nil value disables the telegram auth path.
// TelegramValidator validates Telegram credentials via the connector side-service:
// Mini App launch data (auth) and Login Widget data (linking, Stage 11).
// *connector.Client implements it; a nil value disables the telegram auth and
// telegram-link paths.
type TelegramValidator interface {
ValidateInitData(ctx context.Context, initData string) (connector.User, error)
ValidateLoginWidget(ctx context.Context, data string) (connector.User, error)
}
// NewRegistry builds the slice's message-type catalog over the backend client.
@@ -98,6 +101,7 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator) *Registry
r.ops[MsgChatList] = Op{Handler: chatListHandler(backend), Auth: true}
r.ops[MsgChatNudge] = Op{Handler: nudgeHandler(backend), Auth: true}
registerStage8(r, backend)
registerStage11(r, backend, tg)
return r
}
@@ -118,6 +122,9 @@ func DomainCode(err error) (string, bool) {
if errors.Is(err, connector.ErrInvalidInitData) {
return "invalid_init_data", true
}
if errors.Is(err, connector.ErrInvalidLoginWidget) {
return "invalid_login_widget", true
}
return "", false
}
@@ -0,0 +1,87 @@
package transcode
import (
"context"
"scrabble/gateway/internal/backendclient"
fb "scrabble/pkg/fbs/scrabblefb"
)
// Stage 11 account linking & merge message types. The email ops carry the costly-
// email rate flag; the telegram ops validate Login Widget data through the
// connector (registered only when the connector is configured). All are
// authenticated. The merge ops are the explicit irreversible step, gated in the UI
// after a merge_required confirm.
const (
MsgLinkEmailRequest = "link.email.request"
MsgLinkEmailConfirm = "link.email.confirm"
MsgLinkEmailMerge = "link.email.merge"
MsgLinkTelegram = "link.telegram.confirm"
MsgLinkTelegramMerge = "link.telegram.merge"
)
// registerStage11 adds the linking & merge operations. The telegram ops need the
// connector's Login Widget validator, so they are registered only when tg is set.
func registerStage11(r *Registry, backend *backendclient.Client, tg TelegramValidator) {
r.ops[MsgLinkEmailRequest] = Op{Handler: linkEmailRequestHandler(backend), Auth: true, Email: true}
r.ops[MsgLinkEmailConfirm] = Op{Handler: linkEmailConfirmHandler(backend), Auth: true, Email: true}
r.ops[MsgLinkEmailMerge] = Op{Handler: linkEmailMergeHandler(backend), Auth: true, Email: true}
if tg != nil {
r.ops[MsgLinkTelegram] = Op{Handler: linkTelegramHandler(backend, tg, false), Auth: true}
r.ops[MsgLinkTelegramMerge] = Op{Handler: linkTelegramHandler(backend, tg, true), Auth: true}
}
}
func linkEmailRequestHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsLinkEmailRequest(req.Payload, 0)
if err := backend.LinkEmailRequest(ctx, req.UserID, string(in.Email())); err != nil {
return nil, err
}
return encodeAck(true), nil
}
}
func linkEmailConfirmHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsLinkEmailConfirm(req.Payload, 0)
res, err := backend.LinkEmailConfirm(ctx, req.UserID, string(in.Email()), string(in.Code()))
if err != nil {
return nil, err
}
return encodeLinkResult(res), nil
}
}
func linkEmailMergeHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsLinkEmailConfirm(req.Payload, 0)
res, err := backend.LinkEmailMerge(ctx, req.UserID, string(in.Email()), string(in.Code()))
if err != nil {
return nil, err
}
return encodeLinkResult(res), nil
}
}
// linkTelegramHandler validates Login Widget data via the connector and then calls
// the backend's link or merge endpoint with the trusted Telegram external id.
func linkTelegramHandler(backend *backendclient.Client, tg TelegramValidator, merge bool) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsLinkTelegramRequest(req.Payload, 0)
user, err := tg.ValidateLoginWidget(ctx, string(in.Data()))
if err != nil {
return nil, err
}
var res backendclient.LinkResultResp
if merge {
res, err = backend.LinkTelegramMerge(ctx, req.UserID, user.ExternalID)
} else {
res, err = backend.LinkTelegram(ctx, req.UserID, user.ExternalID)
}
if err != nil {
return nil, err
}
return encodeLinkResult(res), nil
}
}
@@ -0,0 +1,122 @@
package transcode_test
import (
"context"
"encoding/json"
"net/http"
"testing"
flatbuffers "github.com/google/flatbuffers/go"
"scrabble/gateway/internal/connector"
"scrabble/gateway/internal/transcode"
fb "scrabble/pkg/fbs/scrabblefb"
)
func linkEmailConfirmPayload(email, code string) []byte {
b := flatbuffers.NewBuilder(64)
e := b.CreateString(email)
c := b.CreateString(code)
fb.LinkEmailConfirmStart(b)
fb.LinkEmailConfirmAddEmail(b, e)
fb.LinkEmailConfirmAddCode(b, c)
b.Finish(fb.LinkEmailConfirmEnd(b))
return b.FinishedBytes()
}
func linkTelegramPayload(data string) []byte {
b := flatbuffers.NewBuilder(64)
d := b.CreateString(data)
fb.LinkTelegramRequestStart(b)
fb.LinkTelegramRequestAddData(b, d)
b.Finish(fb.LinkTelegramRequestEnd(b))
return b.FinishedBytes()
}
func TestLinkEmailConfirmMergeRequired(t *testing.T) {
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/user/link/email/confirm" {
t.Errorf("path = %q", r.URL.Path)
}
_, _ = w.Write([]byte(`{"status":"merge_required","secondary_user_id":"b-1","secondary_display_name":"Ann","secondary_games":7,"secondary_friends":3}`))
})
defer cleanup()
reg := transcode.NewRegistry(backend, nil)
op, ok := reg.Lookup(transcode.MsgLinkEmailConfirm)
if !ok {
t.Fatal("link.email.confirm not registered")
}
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: linkEmailConfirmPayload("e@x.com", "123456")})
if err != nil {
t.Fatalf("handler: %v", err)
}
lr := fb.GetRootAsLinkResult(payload, 0)
if string(lr.Status()) != "merge_required" || string(lr.SecondaryUserId()) != "b-1" ||
string(lr.SecondaryDisplayName()) != "Ann" || lr.SecondaryGames() != 7 || lr.SecondaryFriends() != 3 {
t.Fatalf("link result = %q/%q/%q/%d/%d", lr.Status(), lr.SecondaryUserId(), lr.SecondaryDisplayName(), lr.SecondaryGames(), lr.SecondaryFriends())
}
if lr.Session(nil) != nil {
t.Error("a merge_required result must not carry a session")
}
}
func TestLinkEmailMergeSwitchesSession(t *testing.T) {
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/user/link/email/merge" {
t.Errorf("path = %q", r.URL.Path)
}
_, _ = w.Write([]byte(`{"status":"merged","token":"tok-9","profile":{"user_id":"a-1","display_name":"Kaya","is_guest":false}}`))
})
defer cleanup()
reg := transcode.NewRegistry(backend, nil)
op, _ := reg.Lookup(transcode.MsgLinkEmailMerge)
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: linkEmailConfirmPayload("e@x.com", "123456")})
if err != nil {
t.Fatalf("handler: %v", err)
}
lr := fb.GetRootAsLinkResult(payload, 0)
if string(lr.Status()) != "merged" {
t.Fatalf("status = %q, want merged", lr.Status())
}
sess := lr.Session(nil)
if sess == nil {
t.Fatal("a switched merge must carry a session")
}
if string(sess.Token()) != "tok-9" || string(sess.UserId()) != "a-1" {
t.Fatalf("session = %q/%q, want tok-9/a-1", sess.Token(), sess.UserId())
}
}
func TestLinkTelegramValidatesAndForwards(t *testing.T) {
var gotExternalID string
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/user/link/telegram" {
t.Errorf("path = %q", r.URL.Path)
}
var body struct {
ExternalID string `json:"external_id"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
gotExternalID = body.ExternalID
_, _ = w.Write([]byte(`{"status":"linked"}`))
})
defer cleanup()
reg := transcode.NewRegistry(backend, fakeValidator{user: connector.User{ExternalID: "42"}})
op, ok := reg.Lookup(transcode.MsgLinkTelegram)
if !ok {
t.Fatal("link.telegram.confirm not registered")
}
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: linkTelegramPayload("id=42&hash=x")})
if err != nil {
t.Fatalf("handler: %v", err)
}
if gotExternalID != "42" {
t.Errorf("backend external_id = %q, want 42 (the gateway-validated id)", gotExternalID)
}
if string(fb.GetRootAsLinkResult(payload, 0).Status()) != "linked" {
t.Error("expected a linked result")
}
}
@@ -28,8 +28,6 @@ const (
MsgInvitationDecline = "invitation.decline"
MsgInvitationCancel = "invitation.cancel"
MsgProfileUpdate = "profile.update"
MsgEmailBindReq = "email.bind.request"
MsgEmailBindConfirm = "email.bind.confirm"
MsgStatsGet = "stats.get"
MsgGameGCG = "game.gcg"
)
@@ -54,8 +52,6 @@ func registerStage8(r *Registry, backend *backendclient.Client) {
r.ops[MsgInvitationDecline] = Op{Handler: invitationRespondHandler(backend, false), Auth: true}
r.ops[MsgInvitationCancel] = Op{Handler: invitationCancelHandler(backend), Auth: true}
r.ops[MsgProfileUpdate] = Op{Handler: profileUpdateHandler(backend), Auth: true}
r.ops[MsgEmailBindReq] = Op{Handler: emailBindRequestHandler(backend), Auth: true, Email: true}
r.ops[MsgEmailBindConfirm] = Op{Handler: emailBindConfirmHandler(backend), Auth: true, Email: true}
r.ops[MsgStatsGet] = Op{Handler: statsHandler(backend), Auth: true}
r.ops[MsgGameGCG] = Op{Handler: gcgHandler(backend), Auth: true}
}
@@ -250,27 +246,6 @@ func profileUpdateHandler(backend *backendclient.Client) Handler {
}
}
func emailBindRequestHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsEmailBindRequest(req.Payload, 0)
if err := backend.EmailBindRequest(ctx, req.UserID, string(in.Email())); err != nil {
return nil, err
}
return encodeAck(true), nil
}
}
func emailBindConfirmHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsEmailConfirmRequest(req.Payload, 0)
out, err := backend.EmailBindConfirm(ctx, req.UserID, string(in.Email()), string(in.Code()))
if err != nil {
return nil, err
}
return encodeProfile(out), nil
}
}
func statsHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
res, err := backend.Stats(ctx, req.UserID)
@@ -23,6 +23,10 @@ func (f fakeValidator) ValidateInitData(context.Context, string) (connector.User
return f.user, f.err
}
func (f fakeValidator) ValidateLoginWidget(context.Context, string) (connector.User, error) {
return f.user, f.err
}
func telegramLoginPayload(initData string) []byte {
b := flatbuffers.NewBuilder(0)
off := b.CreateString(initData)