// Package transcode is the gateway's FlatBuffers<->REST bridge. Each message type // maps to a handler that decodes the FlatBuffers request payload, calls the // backend over REST, and encodes the FlatBuffers response. The registry is the // authoritative message_type catalog; new operations are added here following the // same pattern (PLAN.md Stage 6 vertical slice). package transcode import ( "context" "errors" "scrabble/gateway/internal/auth" "scrabble/gateway/internal/backendclient" fb "scrabble/pkg/fbs/scrabblefb" ) // Message types in the vertical slice. const ( MsgAuthTelegram = "auth.telegram" MsgAuthGuest = "auth.guest" MsgAuthEmailReq = "auth.email.request" MsgAuthEmailLogin = "auth.email.login" MsgProfileGet = "profile.get" MsgGameSubmitPlay = "game.submit_play" MsgGameState = "game.state" MsgLobbyEnqueue = "lobby.enqueue" MsgLobbyPoll = "lobby.poll" MsgChatPost = "chat.post" MsgGamesList = "games.list" MsgGamePass = "game.pass" MsgGameExchange = "game.exchange" MsgGameResign = "game.resign" MsgGameHint = "game.hint" MsgGameEvaluate = "game.evaluate" MsgGameCheckWord = "game.check_word" MsgGameComplaint = "game.complaint" MsgGameHistory = "game.history" MsgChatList = "chat.list" MsgChatNudge = "chat.nudge" ) // Request is one decoded Execute call. type Request struct { Payload []byte UserID string // resolved account id; empty for auth (unauthenticated) ops ClientIP string } // Handler runs one operation and returns the FlatBuffers response payload. type Handler func(ctx context.Context, req Request) ([]byte, error) // Op is a registered message type and its policy flags. type Op struct { Handler Handler // Auth marks an operation that requires a resolved session (X-User-ID). Auth bool // Email marks the costly email-code path that gets a stricter rate sub-limit. Email bool } // Registry maps message types to their operations. type Registry struct { ops map[string]Op } // NewRegistry builds the slice's message-type catalog over the backend client. // The Telegram auth op is registered only when a validator is supplied (a bot // token is configured); otherwise auth.telegram is simply unknown. func NewRegistry(backend *backendclient.Client, tg auth.TelegramValidator) *Registry { r := &Registry{ops: make(map[string]Op)} if tg != nil { r.ops[MsgAuthTelegram] = Op{Handler: authTelegramHandler(backend, tg)} } r.ops[MsgAuthGuest] = Op{Handler: authGuestHandler(backend)} r.ops[MsgAuthEmailReq] = Op{Handler: authEmailRequestHandler(backend), Email: true} r.ops[MsgAuthEmailLogin] = Op{Handler: authEmailLoginHandler(backend), Email: true} r.ops[MsgProfileGet] = Op{Handler: profileHandler(backend), Auth: true} r.ops[MsgGameSubmitPlay] = Op{Handler: submitPlayHandler(backend), Auth: true} r.ops[MsgGameState] = Op{Handler: gameStateHandler(backend), Auth: true} r.ops[MsgLobbyEnqueue] = Op{Handler: enqueueHandler(backend), Auth: true} r.ops[MsgLobbyPoll] = Op{Handler: pollHandler(backend), Auth: true} r.ops[MsgChatPost] = Op{Handler: chatPostHandler(backend), Auth: true} r.ops[MsgGamesList] = Op{Handler: gamesListHandler(backend), Auth: true} r.ops[MsgGamePass] = Op{Handler: passHandler(backend), Auth: true} r.ops[MsgGameExchange] = Op{Handler: exchangeHandler(backend), Auth: true} r.ops[MsgGameResign] = Op{Handler: resignHandler(backend), Auth: true} r.ops[MsgGameHint] = Op{Handler: hintHandler(backend), Auth: true} r.ops[MsgGameEvaluate] = Op{Handler: evaluateHandler(backend), Auth: true} r.ops[MsgGameCheckWord] = Op{Handler: checkWordHandler(backend), Auth: true} r.ops[MsgGameComplaint] = Op{Handler: complaintHandler(backend), Auth: true} r.ops[MsgGameHistory] = Op{Handler: historyHandler(backend), Auth: true} r.ops[MsgChatList] = Op{Handler: chatListHandler(backend), Auth: true} r.ops[MsgChatNudge] = Op{Handler: nudgeHandler(backend), Auth: true} return r } // Lookup returns the operation for messageType, and whether it is registered. func (r *Registry) Lookup(messageType string) (Op, bool) { op, ok := r.ops[messageType] return op, ok } // DomainCode maps an error to a stable result code to surface in the Execute // envelope, reporting false for an unexpected error the caller should treat as a // transport-level internal failure. func DomainCode(err error) (string, bool) { var apiErr *backendclient.APIError if errors.As(err, &apiErr) { return apiErr.Code, true } if errors.Is(err, auth.ErrInvalidInitData) { return "invalid_init_data", true } return "", false } func authTelegramHandler(backend *backendclient.Client, tg auth.TelegramValidator) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsTelegramLoginRequest(req.Payload, 0) user, err := tg.Validate(string(in.InitData())) if err != nil { return nil, err } sess, err := backend.TelegramAuth(ctx, user.ID) if err != nil { return nil, err } return encodeSession(sess), nil } } func authGuestHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, _ Request) ([]byte, error) { sess, err := backend.GuestAuth(ctx) if err != nil { return nil, err } return encodeSession(sess), nil } } func authEmailRequestHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsEmailRequestRequest(req.Payload, 0) if err := backend.EmailRequest(ctx, string(in.Email())); err != nil { return nil, err } return encodeAck(true), nil } } func authEmailLoginHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsEmailLoginRequest(req.Payload, 0) sess, err := backend.EmailLogin(ctx, string(in.Email()), string(in.Code())) if err != nil { return nil, err } return encodeSession(sess), nil } } func profileHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { p, err := backend.Profile(ctx, req.UserID) if err != nil { return nil, err } return encodeProfile(p), nil } } func submitPlayHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsSubmitPlayRequest(req.Payload, 0) res, err := backend.SubmitPlay(ctx, req.UserID, string(in.GameId()), string(in.Dir()), decodeTiles(in)) if err != nil { return nil, err } return encodeMoveResult(res), nil } } func gameStateHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsStateRequest(req.Payload, 0) st, err := backend.GameState(ctx, req.UserID, string(in.GameId())) if err != nil { return nil, err } return encodeState(st), nil } } func enqueueHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsEnqueueRequest(req.Payload, 0) m, err := backend.Enqueue(ctx, req.UserID, string(in.Variant())) if err != nil { return nil, err } return encodeMatch(m), nil } } func pollHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { m, err := backend.Poll(ctx, req.UserID) if err != nil { return nil, err } return encodeMatch(m), nil } } func chatPostHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsChatPostRequest(req.Payload, 0) c, err := backend.ChatPost(ctx, req.UserID, string(in.GameId()), string(in.Body()), req.ClientIP) if err != nil { return nil, err } return encodeChat(c), nil } } // decodeTiles reads the placed tiles from a SubmitPlayRequest. func decodeTiles(in *fb.SubmitPlayRequest) []backendclient.TileJSON { n := in.TilesLength() tiles := make([]backendclient.TileJSON, 0, n) var t fb.TileRecord for i := 0; i < n; i++ { if in.Tiles(&t, i) { tiles = append(tiles, backendclient.TileJSON{ Row: int(t.Row()), Col: int(t.Col()), Letter: string(t.Letter()), Blank: t.Blank(), }) } } return tiles } // decodeEvalTiles reads the tentative tiles from an EvalRequest. func decodeEvalTiles(in *fb.EvalRequest) []backendclient.TileJSON { n := in.TilesLength() tiles := make([]backendclient.TileJSON, 0, n) var t fb.TileRecord for i := 0; i < n; i++ { if in.Tiles(&t, i) { tiles = append(tiles, backendclient.TileJSON{ Row: int(t.Row()), Col: int(t.Col()), Letter: string(t.Letter()), Blank: t.Blank(), }) } } return tiles } // decodeStringVector reads the exchange tiles from an ExchangeRequest. func decodeStringVector(in *fb.ExchangeRequest) []string { n := in.TilesLength() out := make([]string, 0, n) for i := 0; i < n; i++ { out = append(out, string(in.Tiles(i))) } return out } func gamesListHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { res, err := backend.GamesList(ctx, req.UserID) if err != nil { return nil, err } return encodeGameList(res), nil } } func passHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsGameActionRequest(req.Payload, 0) res, err := backend.Pass(ctx, req.UserID, string(in.GameId())) if err != nil { return nil, err } return encodeMoveResult(res), nil } } func resignHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsGameActionRequest(req.Payload, 0) res, err := backend.Resign(ctx, req.UserID, string(in.GameId())) if err != nil { return nil, err } return encodeMoveResult(res), nil } } func exchangeHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsExchangeRequest(req.Payload, 0) res, err := backend.Exchange(ctx, req.UserID, string(in.GameId()), decodeStringVector(in)) if err != nil { return nil, err } return encodeMoveResult(res), nil } } func hintHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsGameActionRequest(req.Payload, 0) res, err := backend.Hint(ctx, req.UserID, string(in.GameId())) if err != nil { return nil, err } return encodeHintResult(res), nil } } func evaluateHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsEvalRequest(req.Payload, 0) res, err := backend.Evaluate(ctx, req.UserID, string(in.GameId()), string(in.Dir()), decodeEvalTiles(in)) if err != nil { return nil, err } return encodeEvalResult(res), nil } } func checkWordHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsCheckWordRequest(req.Payload, 0) res, err := backend.CheckWord(ctx, req.UserID, string(in.GameId()), string(in.Word())) if err != nil { return nil, err } return encodeWordCheck(res), nil } } func complaintHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsComplaintRequest(req.Payload, 0) if err := backend.Complaint(ctx, req.UserID, string(in.GameId()), string(in.Word()), string(in.Note())); err != nil { return nil, err } return encodeAck(true), nil } } func historyHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsGameActionRequest(req.Payload, 0) res, err := backend.History(ctx, req.UserID, string(in.GameId())) if err != nil { return nil, err } return encodeHistory(res), nil } } func chatListHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsGameActionRequest(req.Payload, 0) res, err := backend.ChatList(ctx, req.UserID, string(in.GameId())) if err != nil { return nil, err } return encodeChatList(res), nil } } func nudgeHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsGameActionRequest(req.Payload, 0) res, err := backend.Nudge(ctx, req.UserID, string(in.GameId())) if err != nil { return nil, err } return encodeChat(res), nil } }