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,250 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/lobby"
|
||||
"scrabble/backend/internal/social"
|
||||
)
|
||||
|
||||
// The JSON DTOs below are the gateway<->backend REST contract. They are explicit
|
||||
// (the domain/engine structs are never serialised directly) and mirror the
|
||||
// FlatBuffers edge tables (pkg/fbs) the gateway transcodes to and from.
|
||||
|
||||
// sessionResponse is the credential returned by every auth endpoint.
|
||||
type sessionResponse struct {
|
||||
Token string `json:"token"`
|
||||
UserID string `json:"user_id"`
|
||||
IsGuest bool `json:"is_guest"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
// okResponse is a simple success acknowledgement.
|
||||
type okResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
|
||||
// resolveResponse maps a session token to its account.
|
||||
type resolveResponse struct {
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
// profileResponse is the authenticated account's own profile.
|
||||
type profileResponse struct {
|
||||
UserID string `json:"user_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
PreferredLanguage string `json:"preferred_language"`
|
||||
TimeZone string `json:"time_zone"`
|
||||
HintBalance int `json:"hint_balance"`
|
||||
BlockChat bool `json:"block_chat"`
|
||||
BlockFriendRequests bool `json:"block_friend_requests"`
|
||||
IsGuest bool `json:"is_guest"`
|
||||
}
|
||||
|
||||
// tileDTO is one placed (or to-place) tile.
|
||||
type tileDTO struct {
|
||||
Row int `json:"row"`
|
||||
Col int `json:"col"`
|
||||
Letter string `json:"letter"`
|
||||
Blank bool `json:"blank"`
|
||||
}
|
||||
|
||||
// moveRecordDTO is a decoded move (a committed play, or a hint preview).
|
||||
type moveRecordDTO struct {
|
||||
Player int `json:"player"`
|
||||
Action string `json:"action"`
|
||||
Dir string `json:"dir"`
|
||||
MainRow int `json:"main_row"`
|
||||
MainCol int `json:"main_col"`
|
||||
Tiles []tileDTO `json:"tiles"`
|
||||
Words []string `json:"words"`
|
||||
Count int `json:"count"`
|
||||
Score int `json:"score"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// seatDTO is one seat's public standing.
|
||||
type seatDTO struct {
|
||||
Seat int `json:"seat"`
|
||||
AccountID string `json:"account_id"`
|
||||
Score int `json:"score"`
|
||||
HintsUsed int `json:"hints_used"`
|
||||
IsWinner bool `json:"is_winner"`
|
||||
}
|
||||
|
||||
// gameDTO is the shared game summary.
|
||||
type gameDTO struct {
|
||||
ID string `json:"id"`
|
||||
Variant string `json:"variant"`
|
||||
DictVersion string `json:"dict_version"`
|
||||
Status string `json:"status"`
|
||||
Players int `json:"players"`
|
||||
ToMove int `json:"to_move"`
|
||||
TurnTimeoutSecs int `json:"turn_timeout_secs"`
|
||||
MoveCount int `json:"move_count"`
|
||||
EndReason string `json:"end_reason"`
|
||||
Seats []seatDTO `json:"seats"`
|
||||
}
|
||||
|
||||
// moveResultDTO is the outcome of a committed move.
|
||||
type moveResultDTO struct {
|
||||
Move moveRecordDTO `json:"move"`
|
||||
Game gameDTO `json:"game"`
|
||||
}
|
||||
|
||||
// stateDTO is a player's view of a game.
|
||||
type stateDTO struct {
|
||||
Game gameDTO `json:"game"`
|
||||
Seat int `json:"seat"`
|
||||
Rack []string `json:"rack"`
|
||||
BagLen int `json:"bag_len"`
|
||||
HintsRemaining int `json:"hints_remaining"`
|
||||
}
|
||||
|
||||
// matchDTO reports whether the caller has been paired into a game.
|
||||
type matchDTO struct {
|
||||
Matched bool `json:"matched"`
|
||||
Game *gameDTO `json:"game,omitempty"`
|
||||
}
|
||||
|
||||
// chatDTO is one stored chat message or nudge.
|
||||
type chatDTO struct {
|
||||
ID string `json:"id"`
|
||||
GameID string `json:"game_id"`
|
||||
SenderID string `json:"sender_id"`
|
||||
Kind string `json:"kind"`
|
||||
Body string `json:"body"`
|
||||
CreatedAtUnix int64 `json:"created_at_unix"`
|
||||
}
|
||||
|
||||
// errorResponse is the uniform error envelope.
|
||||
type errorResponse struct {
|
||||
Error errorBody `json:"error"`
|
||||
}
|
||||
|
||||
type errorBody struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// sessionResponseFor builds the credential payload for a minted session.
|
||||
func sessionResponseFor(token string, acc account.Account) sessionResponse {
|
||||
return sessionResponse{
|
||||
Token: token,
|
||||
UserID: acc.ID.String(),
|
||||
IsGuest: acc.IsGuest,
|
||||
DisplayName: acc.DisplayName,
|
||||
}
|
||||
}
|
||||
|
||||
// profileResponseFor projects an account into its profile DTO.
|
||||
func profileResponseFor(acc account.Account) profileResponse {
|
||||
return profileResponse{
|
||||
UserID: acc.ID.String(),
|
||||
DisplayName: acc.DisplayName,
|
||||
PreferredLanguage: acc.PreferredLanguage,
|
||||
TimeZone: acc.TimeZone,
|
||||
HintBalance: acc.HintBalance,
|
||||
BlockChat: acc.BlockChat,
|
||||
BlockFriendRequests: acc.BlockFriendRequests,
|
||||
IsGuest: acc.IsGuest,
|
||||
}
|
||||
}
|
||||
|
||||
// gameDTOFromGame projects a game.Game into its DTO.
|
||||
func gameDTOFromGame(g game.Game) gameDTO {
|
||||
seats := make([]seatDTO, 0, len(g.Seats))
|
||||
for _, s := range g.Seats {
|
||||
seats = append(seats, seatDTO{
|
||||
Seat: s.Seat,
|
||||
AccountID: s.AccountID.String(),
|
||||
Score: s.Score,
|
||||
HintsUsed: s.HintsUsed,
|
||||
IsWinner: s.IsWinner,
|
||||
})
|
||||
}
|
||||
return gameDTO{
|
||||
ID: g.ID.String(),
|
||||
Variant: g.Variant.String(),
|
||||
DictVersion: g.DictVersion,
|
||||
Status: g.Status,
|
||||
Players: g.Players,
|
||||
ToMove: g.ToMove,
|
||||
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
|
||||
MoveCount: g.MoveCount,
|
||||
EndReason: g.EndReason,
|
||||
Seats: seats,
|
||||
}
|
||||
}
|
||||
|
||||
// moveRecordDTOFrom projects an engine move record into its DTO.
|
||||
func moveRecordDTOFrom(m engine.MoveRecord) moveRecordDTO {
|
||||
tiles := make([]tileDTO, 0, len(m.Tiles))
|
||||
for _, t := range m.Tiles {
|
||||
tiles = append(tiles, tileDTO{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank})
|
||||
}
|
||||
return moveRecordDTO{
|
||||
Player: m.Player,
|
||||
Action: m.Action.String(),
|
||||
Dir: m.Dir.String(),
|
||||
MainRow: m.MainRow,
|
||||
MainCol: m.MainCol,
|
||||
Tiles: tiles,
|
||||
Words: m.Words,
|
||||
Count: m.Count,
|
||||
Score: m.Score,
|
||||
Total: m.Total,
|
||||
}
|
||||
}
|
||||
|
||||
// moveResultDTOFrom projects a committed move result into its DTO.
|
||||
func moveResultDTOFrom(r game.MoveResult) moveResultDTO {
|
||||
return moveResultDTO{Move: moveRecordDTOFrom(r.Move), Game: gameDTOFromGame(r.Game)}
|
||||
}
|
||||
|
||||
// stateDTOFrom projects a player's state view into its DTO.
|
||||
func stateDTOFrom(v game.StateView) stateDTO {
|
||||
return stateDTO{
|
||||
Game: gameDTOFromGame(v.Game),
|
||||
Seat: v.Seat,
|
||||
Rack: v.Rack,
|
||||
BagLen: v.BagLen,
|
||||
HintsRemaining: v.HintsRemaining,
|
||||
}
|
||||
}
|
||||
|
||||
// matchDTOFrom projects an enqueue/poll result into its DTO.
|
||||
func matchDTOFrom(r lobby.EnqueueResult) matchDTO {
|
||||
if !r.Matched {
|
||||
return matchDTO{Matched: false}
|
||||
}
|
||||
g := gameDTOFromGame(r.Game)
|
||||
return matchDTO{Matched: true, Game: &g}
|
||||
}
|
||||
|
||||
// chatDTOFrom projects a chat message into its DTO.
|
||||
func chatDTOFrom(m social.Message) chatDTO {
|
||||
return chatDTO{
|
||||
ID: m.ID.String(),
|
||||
GameID: m.GameID.String(),
|
||||
SenderID: m.SenderID.String(),
|
||||
Kind: m.Kind,
|
||||
Body: m.Body,
|
||||
CreatedAtUnix: m.CreatedAt.Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
// parseDirection maps the wire direction string to an engine.Direction.
|
||||
func parseDirection(s string) (engine.Direction, bool) {
|
||||
switch strings.ToUpper(strings.TrimSpace(s)) {
|
||||
case "H":
|
||||
return engine.Horizontal, true
|
||||
case "V":
|
||||
return engine.Vertical, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user