Stage 6: gateway edge (Connect/FlatBuffers over h2c, platform/email/guest auth, sessions, rate-limit, admin passthrough, live push bridge)
New public ingress and the first network edge. Framework + a vertical slice of operations end-to-end; remaining ops reuse the same transcode pattern in Stage 7. Contracts (new module scrabble/pkg): - push.proto (backend->gateway gRPC server-stream) + scrabble.fbs (FlatBuffers edge payloads), committed generated Go; buf/flatc Makefiles (dev-time codegen). Backend: - REST handlers on the /api/v1 groups: internal session endpoints (telegram/guest/email login -> mint, resolve, revoke) and the user slice (profile, submit_play, state, lobby enqueue/poll, chat). - internal/notify in-process Publisher hub + internal/pushgrpc gRPC server (BACKEND_GRPC_ADDR) streaming your_turn/opponent_moved/chat/nudge/match_found; emission in game.commit, social, matchmaker. - migration 00005 accounts.is_guest; guests are durable rows excluded from stats; ProvisionGuest; email-as-login (RequestLoginCode/LoginWithCode). Gateway (new module scrabble/gateway): - Connect Gateway service over h2c (Execute + Subscribe), FlatBuffers<->JSON transcode registry, Telegram initData HMAC validator (seam), session cache, token-bucket rate limiter (3 classes), push fan-out hub, backend REST + push gRPC client, admin Basic-Auth reverse proxy. go.work: use ./pkg, ./gateway + replace scrabble/pkg. CI: gateway/**, pkg/** path filters; unit build/vet/test span all three modules. Docs (PLAN, ARCHITECTURE, FUNCTIONAL+ru, TESTING, READMEs) updated; gateway/pkg unit tests + guest/email-login integration tests.
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// 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}
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user